From dae47e1e75c6ea81121da10a7fbf473f2728dbed Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:22:27 +0700 Subject: [PATCH 1/6] feat: shared HTTP + three-tier cache for MCP asset fetchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `mcp-server/src/asset-fetchers/` with two reusable primitives that upcoming icon and stock-photo tools share: - `http.js` — `fetchWithTimeout` / `fetchText` / `fetchJson` / `fetchBinary` built on native fetch + AbortSignal.timeout. No new runtime deps. - `cache.js` — generic three-tier cache (in-flight Map → in-memory → disk) cloned from the emoji-loader pattern, parameterised so icons, search results, and image bytes can all reuse it. TTL support for search caches. Caches live under `~/.cache/drawd-mcp/` to match the existing convention. --- .../asset-fetchers/__tests__/cache.test.js | 85 ++++++++++ mcp-server/src/asset-fetchers/cache.js | 155 ++++++++++++++++++ mcp-server/src/asset-fetchers/http.js | 74 +++++++++ 3 files changed, 314 insertions(+) create mode 100644 mcp-server/src/asset-fetchers/__tests__/cache.test.js create mode 100644 mcp-server/src/asset-fetchers/cache.js create mode 100644 mcp-server/src/asset-fetchers/http.js diff --git a/mcp-server/src/asset-fetchers/__tests__/cache.test.js b/mcp-server/src/asset-fetchers/__tests__/cache.test.js new file mode 100644 index 0000000..da5de95 --- /dev/null +++ b/mcp-server/src/asset-fetchers/__tests__/cache.test.js @@ -0,0 +1,85 @@ +// @vitest-environment node +// +// Cache uses real fs/promises, so use the node env (avoid jsdom intercepting node: imports). + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Cache, hashKey } from "../cache.js"; + +const uniqueSubdir = () => + `test-cache-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +describe("hashKey", () => { + it("produces a stable hex string", () => { + expect(hashKey("foo")).toMatch(/^[0-9a-f]{40}$/); + expect(hashKey("foo")).toBe(hashKey("foo")); + expect(hashKey("foo")).not.toBe(hashKey("bar")); + }); +}); + +describe("Cache", () => { + let cache; + beforeEach(() => { + cache = new Cache({ subdir: uniqueSubdir(), encoding: "text" }); + }); + + it("caches the fetcher result in memory after first call", async () => { + const fetcher = vi.fn(async () => "value-1"); + const r1 = await cache.getOrFetch("k", fetcher); + const r2 = await cache.getOrFetch("k", fetcher); + expect(r1).toBe("value-1"); + expect(r2).toBe("value-1"); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("dedupes concurrent in-flight requests", async () => { + let resolveOuter; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + resolveOuter = resolve; + }) + ); + // disable disk reads so the in-flight slot is set synchronously enough + cache.readDisk = async () => null; + const p1 = cache.getOrFetch("dup", fetcher); + const p2 = cache.getOrFetch("dup", fetcher); + // Yield microtasks so the readDisk(null) await resolves and the fetcher fires. + await Promise.resolve(); + await Promise.resolve(); + expect(fetcher).toHaveBeenCalledTimes(1); + resolveOuter("dup-value"); + const [v1, v2] = await Promise.all([p1, p2]); + expect(v1).toBe("dup-value"); + expect(v2).toBe("dup-value"); + }); + + it("falls back to fetcher again after clearMemory", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce("v1") + .mockResolvedValueOnce("v2"); + + const r1 = await cache.getOrFetch("k", fetcher); + cache.clearMemory(); + // disk write is fire-and-forget; it may or may not have landed before + // the next call. To make this test deterministic, override readDisk. + cache.readDisk = async () => null; + const r2 = await cache.getOrFetch("k", fetcher); + expect(r1).toBe("v1"); + expect(r2).toBe("v2"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("propagates fetcher errors and removes the in-flight slot", async () => { + const err = new Error("boom"); + const fetcher = vi + .fn() + .mockRejectedValueOnce(err) + .mockResolvedValueOnce("recovered"); + + await expect(cache.getOrFetch("err", fetcher)).rejects.toThrow("boom"); + cache.readDisk = async () => null; + const r2 = await cache.getOrFetch("err", fetcher); + expect(r2).toBe("recovered"); + }); +}); diff --git a/mcp-server/src/asset-fetchers/cache.js b/mcp-server/src/asset-fetchers/cache.js new file mode 100644 index 0000000..7027831 --- /dev/null +++ b/mcp-server/src/asset-fetchers/cache.js @@ -0,0 +1,155 @@ +/** + * Three-tier cache: in-flight Map → in-memory Map → on-disk file. + * + * The pattern is lifted directly from `renderer/emoji-loader.js` and + * generalised so icons, search results, and image bytes can all share it. + * + * - In-flight: dedupes concurrent requests for the same key into one fetch. + * - In-memory: process-lifetime cache for repeated lookups. + * - On-disk: survives process restarts, stored under + * `~/.cache/drawd-mcp//`. + * + * Disk failures (read-only FS, permissions, ENOSPC) are silently ignored + * — the in-memory tier still serves the value for the rest of the session. + */ + +import { readFile, writeFile, mkdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; + +const ROOT = join(homedir(), ".cache", "drawd-mcp"); + +/** + * Create a stable, filename-safe key from any string input. + * Used by callers that want to cache by URL or query. + */ +export function hashKey(input) { + return createHash("sha1").update(String(input)).digest("hex"); +} + +/** + * Generic three-tier cache. + * + * @template T + */ +export class Cache { + /** + * @param {object} opts + * @param {string} opts.subdir Subdirectory under ~/.cache/drawd-mcp + * @param {(filename: string) => Promise} [opts.readDisk] + * @param {(filename: string, value: T) => Promise} [opts.writeDisk] + * @param {string} [opts.extension=".bin"] Used by the default disk readers/writers + * @param {"text"|"binary"|"json"} [opts.encoding="text"] + * @param {number|null} [opts.ttlMs=null] Optional TTL for disk entries (search results) + */ + constructor(opts) { + this.subdir = opts.subdir; + this.encoding = opts.encoding || "text"; + this.extension = opts.extension || ( + this.encoding === "binary" ? ".bin" : + this.encoding === "json" ? ".json" : + ".txt" + ); + this.ttlMs = opts.ttlMs ?? null; + this.readDiskFn = opts.readDisk; + this.writeDiskFn = opts.writeDisk; + + this.dir = join(ROOT, this.subdir); + this.memory = new Map(); + this.inFlight = new Map(); + this._dirEnsured = null; + } + + async ensureDir() { + if (!this._dirEnsured) { + this._dirEnsured = mkdir(this.dir, { recursive: true }).catch(() => null); + } + return this._dirEnsured; + } + + /** Resolve a key to an absolute disk path. */ + pathFor(filename) { + return join(this.dir, filename + this.extension); + } + + async readDisk(filename) { + if (this.readDiskFn) return this.readDiskFn(filename, this); + const file = this.pathFor(filename); + try { + if (this.ttlMs != null) { + const s = await stat(file); + if (Date.now() - s.mtimeMs > this.ttlMs) return null; + } + if (this.encoding === "binary") { + return await readFile(file); + } else if (this.encoding === "json") { + const txt = await readFile(file, "utf8"); + return JSON.parse(txt); + } + return await readFile(file, "utf8"); + } catch { + return null; + } + } + + async writeDisk(filename, value) { + if (this.writeDiskFn) return this.writeDiskFn(filename, value, this); + try { + await this.ensureDir(); + const file = this.pathFor(filename); + if (this.encoding === "binary") { + await writeFile(file, value); + } else if (this.encoding === "json") { + await writeFile(file, JSON.stringify(value), "utf8"); + } else { + await writeFile(file, value, "utf8"); + } + } catch { + // Silently ignore disk failures. + } + } + + /** + * Get a value, falling back through tiers in order. + * + * @param {string} key Logical cache key (e.g. URL). + * @param {() => Promise} fetcher Network fallback. + * @param {object} [opts] + * @param {string} [opts.filename=hashKey(key)] Custom on-disk filename (no extension). + * @returns {Promise} + */ + async getOrFetch(key, fetcher, opts = {}) { + if (this.memory.has(key)) return this.memory.get(key); + + const inFlight = this.inFlight.get(key); + if (inFlight) return inFlight; + + const filename = opts.filename || hashKey(key); + + const promise = (async () => { + const fromDisk = await this.readDisk(filename); + if (fromDisk != null) { + this.memory.set(key, fromDisk); + return fromDisk; + } + const fresh = await fetcher(); + this.memory.set(key, fresh); + // Fire-and-forget the disk write — never let cache writes block callers. + this.writeDisk(filename, fresh).catch(() => {}); + return fresh; + })() + .finally(() => { + this.inFlight.delete(key); + }); + + this.inFlight.set(key, promise); + return promise; + } + + /** Clear the in-memory tier (disk entries survive). Mostly used by tests. */ + clearMemory() { + this.memory.clear(); + this.inFlight.clear(); + } +} diff --git a/mcp-server/src/asset-fetchers/http.js b/mcp-server/src/asset-fetchers/http.js new file mode 100644 index 0000000..f1629d2 --- /dev/null +++ b/mcp-server/src/asset-fetchers/http.js @@ -0,0 +1,74 @@ +/** + * Shared HTTP helper for asset providers (Iconify, Unsplash, Pexels, Picsum, + * and the renderer's image-inlining pre-pass). + * + * Wraps native fetch() with AbortSignal.timeout() so callers don't have to + * wire up the same boilerplate per call. Defaults are tuned for asset + * fetches: 4 s for SVG/JSON, 8 s for binary photo downloads. + */ + +export const DEFAULT_TIMEOUT_MS = 4000; +export const DEFAULT_BINARY_TIMEOUT_MS = 8000; + +/** + * @param {string} url + * @param {object} [opts] + * @param {number} [opts.timeoutMs] Override per-call timeout. + * @param {Record} [opts.headers] + * @param {string} [opts.method="GET"] + * @returns {Promise} + */ +export async function fetchWithTimeout(url, opts = {}) { + const { + timeoutMs = DEFAULT_TIMEOUT_MS, + headers, + method = "GET", + } = opts; + + const res = await fetch(url, { + method, + headers, + signal: AbortSignal.timeout(timeoutMs), + }); + return res; +} + +/** + * Convenience: fetch a URL and return the body as text. + * Throws on non-2xx with a message that includes the status code. + */ +export async function fetchText(url, opts = {}) { + const res = await fetchWithTimeout(url, opts); + if (!res.ok) { + throw new Error(`HTTP ${res.status} for ${url}`); + } + return await res.text(); +} + +/** + * Convenience: fetch a URL and return the parsed JSON body. + */ +export async function fetchJson(url, opts = {}) { + const res = await fetchWithTimeout(url, opts); + if (!res.ok) { + throw new Error(`HTTP ${res.status} for ${url}`); + } + return await res.json(); +} + +/** + * Convenience: fetch a URL as raw bytes. Returns { bytes, contentType }. + * Used by the image-inlining pre-pass. + */ +export async function fetchBinary(url, opts = {}) { + const res = await fetchWithTimeout(url, { + timeoutMs: DEFAULT_BINARY_TIMEOUT_MS, + ...opts, + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status} for ${url}`); + } + const buffer = Buffer.from(await res.arrayBuffer()); + const contentType = res.headers.get("content-type") || "application/octet-stream"; + return { bytes: buffer, contentType }; +} From 35f1cd3c5467bce99122b56106f42f48539d4127 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:22:35 +0700 Subject: [PATCH 2/6] feat: Iconify provider for the MCP server Adds `asset-fetchers/iconify.js` wrapping the public Iconify HTTP API: - `fetchIcon(collection, name, {size, color})` returns the SVG body. - `searchIcons(query, {prefix, limit})` returns ranked candidate icon IDs. Slug components are validated against `/^[a-z0-9][a-z0-9-]*$/` to prevent path-traversal via crafted names. Iconify's stub-empty `` "not found" responses are normalised to a clear error. SVGs are cached forever (immutable per id); search results have a 7-day TTL. Both share the new three-tier `Cache` class. --- .../asset-fetchers/__tests__/iconify.test.js | 95 +++++++++++++ mcp-server/src/asset-fetchers/iconify.js | 133 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 mcp-server/src/asset-fetchers/__tests__/iconify.test.js create mode 100644 mcp-server/src/asset-fetchers/iconify.js diff --git a/mcp-server/src/asset-fetchers/__tests__/iconify.test.js b/mcp-server/src/asset-fetchers/__tests__/iconify.test.js new file mode 100644 index 0000000..8cbd1c4 --- /dev/null +++ b/mcp-server/src/asset-fetchers/__tests__/iconify.test.js @@ -0,0 +1,95 @@ +// @vitest-environment node + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { fetchIcon, searchIcons, _internal } from "../iconify.js"; + +function mockFetchOnce(body, { ok = true, status = 200 } = {}) { + const res = { + ok, + status, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), + json: async () => (typeof body === "string" ? JSON.parse(body) : body), + headers: new Map(), + }; + global.fetch = vi.fn(async () => res); +} + +describe("iconify.fetchIcon", () => { + beforeEach(() => { + _internal.svgCache.clearMemory(); + // disable disk reads so tests don't depend on fs state + _internal.svgCache.readDisk = async () => null; + }); + + it("constructs the iconify URL with size and color params", async () => { + mockFetchOnce(""); + await fetchIcon("mdi", "home", { size: 32, color: "#ff0000" }); + const calledUrl = global.fetch.mock.calls[0][0]; + expect(calledUrl).toContain("https://api.iconify.design/mdi/home.svg"); + expect(calledUrl).toContain("height=32"); + expect(calledUrl).toContain("color=%23ff0000"); + }); + + it("rejects unsafe collection slugs", async () => { + await expect(fetchIcon("../etc", "home")).rejects.toThrow(/Invalid iconify collection/); + }); + + it("rejects unsafe icon names", async () => { + await expect(fetchIcon("mdi", "home/passwd")).rejects.toThrow(/Invalid iconify icon/); + }); + + it("rejects empty SVG bodies as not-found", async () => { + mockFetchOnce(""); + await expect(fetchIcon("mdi", "missing")).rejects.toThrow(/Icon not found/); + }); + + it("returns the SVG body verbatim on success", async () => { + const body = ""; + mockFetchOnce(body); + const out = await fetchIcon("mdi", "home"); + expect(out).toBe(body); + }); + + it("uses cache on second lookup", async () => { + const body = ""; + mockFetchOnce(body); + await fetchIcon("mdi", "home"); + await fetchIcon("mdi", "home"); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe("iconify.searchIcons", () => { + beforeEach(() => { + _internal.searchCache.clearMemory(); + _internal.searchCache.readDisk = async () => null; + }); + + it("returns normalized {results,total} from iconify response", async () => { + mockFetchOnce({ icons: ["mdi:home", "ph:house"] }); + const out = await searchIcons("home"); + expect(out.total).toBe(2); + expect(out.results).toEqual([ + { id: "mdi:home", collection: "mdi", name: "home" }, + { id: "ph:house", collection: "ph", name: "house" }, + ]); + }); + + it("clamps limit between 1 and 64", async () => { + mockFetchOnce({ icons: [] }); + await searchIcons("home", { limit: 9999 }); + const calledUrl = global.fetch.mock.calls[0][0]; + expect(calledUrl).toContain("limit=64"); + }); + + it("requires a non-empty query", async () => { + await expect(searchIcons("")).rejects.toThrow(/query is required/); + }); + + it("passes prefix when provided", async () => { + mockFetchOnce({ icons: [] }); + await searchIcons("home", { prefix: "mdi" }); + const calledUrl = global.fetch.mock.calls[0][0]; + expect(calledUrl).toContain("prefix=mdi"); + }); +}); diff --git a/mcp-server/src/asset-fetchers/iconify.js b/mcp-server/src/asset-fetchers/iconify.js new file mode 100644 index 0000000..3b08592 --- /dev/null +++ b/mcp-server/src/asset-fetchers/iconify.js @@ -0,0 +1,133 @@ +/** + * Iconify provider. + * + * Iconify exposes 275k+ icons across collections like mdi (Material Design), + * ph (Phosphor), lucide, tabler, heroicons, solar, carbon. We hit two + * endpoints: + * GET https://api.iconify.design/{collection}/{name}.svg?height=...&color=... + * GET https://api.iconify.design/search?query=...&limit=...&prefix=... + * + * No API key required. SVGs are immutable per (collection, name) so the + * disk cache has no TTL. Search results have a 7-day TTL because Iconify's + * ranking can drift. + */ + +import { Cache } from "./cache.js"; +import { fetchText, fetchJson } from "./http.js"; + +export const ICONIFY_HOST = "api.iconify.design"; + +/** Curated list of collections we recommend in tool descriptions. Advisory only. */ +export const RECOMMENDED_COLLECTIONS = [ + "mdi", + "ph", + "lucide", + "tabler", + "heroicons", + "solar", + "carbon", +]; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +const svgCache = new Cache({ + subdir: "icons", + encoding: "text", + extension: ".svg", +}); + +const searchCache = new Cache({ + subdir: "icon-search", + encoding: "json", + ttlMs: SEVEN_DAYS_MS, +}); + +/** + * Validate a single iconify slug component (collection or icon name). + * Iconify slugs are restricted to lowercase letters, digits, and hyphens. + * Reject anything else to prevent path-traversal / SSRF via crafted names. + */ +function isSafeSlug(s) { + return typeof s === "string" && /^[a-z0-9][a-z0-9-]*$/.test(s); +} + +/** + * Fetch one icon by collection + name. + * + * @param {string} collection e.g. "mdi" + * @param {string} name e.g. "home" + * @param {object} [opts] + * @param {number} [opts.size=24] + * @param {string} [opts.color="currentColor"] + * @returns {Promise} SVG text + */ +export async function fetchIcon(collection, name, opts = {}) { + if (!isSafeSlug(collection)) { + throw new Error(`Invalid iconify collection slug: ${collection}`); + } + if (!isSafeSlug(name)) { + throw new Error(`Invalid iconify icon name: ${name}`); + } + + const size = Number.isFinite(opts.size) ? Math.max(1, Math.min(512, opts.size)) : 24; + const color = typeof opts.color === "string" && opts.color.length > 0 + ? opts.color + : "currentColor"; + + const url = `https://${ICONIFY_HOST}/${collection}/${encodeURIComponent(name)}.svg?height=${size}&color=${encodeURIComponent(color)}`; + + const cacheKey = `${collection}:${name}:${size}:${color}`; + const filename = `${collection}__${name}__${size}__${encodeURIComponent(color)}`; + + return svgCache.getOrFetch(cacheKey, async () => { + const text = await fetchText(url); + // Iconify returns a 200 with an SVG containing `` for not-found + // glyphs. Normalise that to a clear error. + if (!text.includes("]*>\s*<\/svg>/.test(text)) { + throw new Error(`Icon not found: ${collection}:${name}`); + } + return text; + }, { filename }); +} + +/** + * Search Iconify across all (or one) collections. + * + * @param {string} query + * @param {object} [opts] + * @param {string} [opts.prefix] Restrict to one collection. + * @param {number} [opts.limit=12] Max results (1..64). + * @returns {Promise<{results: Array<{id:string, collection:string, name:string}>, total:number}>} + */ +export async function searchIcons(query, opts = {}) { + if (typeof query !== "string" || query.trim() === "") { + throw new Error("query is required"); + } + const limit = Math.max( + 1, + Math.min(64, Number.isFinite(opts.limit) ? opts.limit : 12), + ); + const prefix = opts.prefix && isSafeSlug(opts.prefix) ? opts.prefix : null; + + const params = new URLSearchParams({ + query: query.trim(), + limit: String(limit), + }); + if (prefix) params.set("prefix", prefix); + + const url = `https://${ICONIFY_HOST}/search?${params.toString()}`; + const cacheKey = `${query}|${prefix || ""}|${limit}`; + + return searchCache.getOrFetch(cacheKey, async () => { + const json = await fetchJson(url); + const icons = Array.isArray(json?.icons) ? json.icons : []; + const results = icons.map((id) => { + const [collection, ...rest] = String(id).split(":"); + return { id, collection, name: rest.join(":") }; + }); + return { results, total: results.length }; + }); +} + +// Exposed for tests. +export const _internal = { svgCache, searchCache }; From 9e3a34d461a2b89a2cb5eeaa0e799cebfaa5a782 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:22:45 +0700 Subject: [PATCH 3/6] feat: stock-photo providers (Unsplash, Pexels, Picsum) with fallback chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three photo providers behind a uniform `searchPhotos(query, {limit})` interface plus an orchestrator that picks among them based on configured API keys. - `picsum.js` — keyless deterministic seeded URLs. Always available. - `unsplash.js` — reads `UNSPLASH_ACCESS_KEY` per call. Throws a typed `MissingApiKeyError` when unset so the orchestrator can fall through. - `pexels.js` — same pattern, reads `PEXELS_API_KEY`. - `index.js` — `findStockImage(query, {source, limit})` implements the `unsplash → pexels → picsum` chain. Includes a `warning` field in the result envelope when a keyed source was skipped silently. API keys are read from env on every call — never logged, never written to disk, never stashed on module state. --- .../__tests__/orchestrator.test.js | 94 +++++++++++++++++++ .../asset-fetchers/__tests__/picsum.test.js | 36 +++++++ mcp-server/src/asset-fetchers/errors.js | 13 +++ mcp-server/src/asset-fetchers/index.js | 79 ++++++++++++++++ mcp-server/src/asset-fetchers/pexels.js | 72 ++++++++++++++ mcp-server/src/asset-fetchers/picsum.js | 59 ++++++++++++ mcp-server/src/asset-fetchers/unsplash.js | 73 ++++++++++++++ 7 files changed, 426 insertions(+) create mode 100644 mcp-server/src/asset-fetchers/__tests__/orchestrator.test.js create mode 100644 mcp-server/src/asset-fetchers/__tests__/picsum.test.js create mode 100644 mcp-server/src/asset-fetchers/errors.js create mode 100644 mcp-server/src/asset-fetchers/index.js create mode 100644 mcp-server/src/asset-fetchers/pexels.js create mode 100644 mcp-server/src/asset-fetchers/picsum.js create mode 100644 mcp-server/src/asset-fetchers/unsplash.js diff --git a/mcp-server/src/asset-fetchers/__tests__/orchestrator.test.js b/mcp-server/src/asset-fetchers/__tests__/orchestrator.test.js new file mode 100644 index 0000000..a18bd03 --- /dev/null +++ b/mcp-server/src/asset-fetchers/__tests__/orchestrator.test.js @@ -0,0 +1,94 @@ +// @vitest-environment node + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { findStockImage } from "../index.js"; +import { _internal as unsplashInternal } from "../unsplash.js"; +import { _internal as pexelsInternal } from "../pexels.js"; + +const ORIG_UNSPLASH = process.env.UNSPLASH_ACCESS_KEY; +const ORIG_PEXELS = process.env.PEXELS_API_KEY; + +function mockFetchJson(body) { + const res = { + ok: true, + status: 200, + json: async () => body, + text: async () => JSON.stringify(body), + headers: new Map(), + }; + global.fetch = vi.fn(async () => res); +} + +beforeEach(() => { + delete process.env.UNSPLASH_ACCESS_KEY; + delete process.env.PEXELS_API_KEY; + unsplashInternal.searchCache.clearMemory(); + unsplashInternal.searchCache.readDisk = async () => null; + unsplashInternal.searchCache.writeDisk = async () => {}; + pexelsInternal.searchCache.clearMemory(); + pexelsInternal.searchCache.readDisk = async () => null; + pexelsInternal.searchCache.writeDisk = async () => {}; +}); + +afterEach(() => { + if (ORIG_UNSPLASH != null) process.env.UNSPLASH_ACCESS_KEY = ORIG_UNSPLASH; + if (ORIG_PEXELS != null) process.env.PEXELS_API_KEY = ORIG_PEXELS; +}); + +describe("findStockImage — fallback chain", () => { + it("falls all the way through to Picsum when no keys are set", async () => { + const out = await findStockImage("kitchen", { limit: 3 }); + expect(out.source).toBe("picsum"); + expect(out.results.length).toBe(3); + expect(out.warning).toMatch(/UNSPLASH_ACCESS_KEY/); + expect(out.warning).toMatch(/PEXELS_API_KEY/); + }); + + it("uses unsplash when UNSPLASH_ACCESS_KEY is set", async () => { + process.env.UNSPLASH_ACCESS_KEY = "test-key"; + mockFetchJson({ + results: [ + { + urls: { regular: "https://images.unsplash.com/abc", small: "https://images.unsplash.com/abc-sm" }, + alt_description: "kitchen", + user: { name: "Alice" }, + width: 1200, + height: 800, + }, + ], + }); + const out = await findStockImage("kitchen", { limit: 1 }); + expect(out.source).toBe("unsplash"); + expect(out.results[0].url).toBe("https://images.unsplash.com/abc"); + expect(out.results[0].attribution).toContain("Alice"); + expect(out.warning).toBeUndefined(); + }); + + it("respects explicit source override", async () => { + const out = await findStockImage("kitchen", { source: "picsum", limit: 1 }); + expect(out.source).toBe("picsum"); + expect(out.warning).toBeUndefined(); + }); + + it("falls through when explicit source has no key", async () => { + const out = await findStockImage("kitchen", { source: "unsplash", limit: 1 }); + expect(out.source).toBe("picsum"); + expect(out.warning).toMatch(/UNSPLASH_ACCESS_KEY/); + }); + + it("sends Authorization header for unsplash", async () => { + process.env.UNSPLASH_ACCESS_KEY = "k1"; + mockFetchJson({ results: [] }); + await findStockImage("a", { source: "unsplash" }); + const headers = global.fetch.mock.calls[0][1].headers; + expect(headers.Authorization).toBe("Client-ID k1"); + }); + + it("sends Authorization header for pexels", async () => { + process.env.PEXELS_API_KEY = "k2"; + mockFetchJson({ photos: [] }); + await findStockImage("a", { source: "pexels" }); + const headers = global.fetch.mock.calls[0][1].headers; + expect(headers.Authorization).toBe("k2"); + }); +}); diff --git a/mcp-server/src/asset-fetchers/__tests__/picsum.test.js b/mcp-server/src/asset-fetchers/__tests__/picsum.test.js new file mode 100644 index 0000000..5902786 --- /dev/null +++ b/mcp-server/src/asset-fetchers/__tests__/picsum.test.js @@ -0,0 +1,36 @@ +// @vitest-environment node + +import { describe, it, expect } from "vitest"; +import { searchPhotos } from "../picsum.js"; + +describe("picsum.searchPhotos", () => { + it("returns the requested number of results (default 5)", () => { + const r = searchPhotos("kitchen"); + expect(r.results).toHaveLength(5); + }); + + it("clamps limit between 1 and 20", () => { + expect(searchPhotos("a", { limit: 9999 }).results).toHaveLength(20); + expect(searchPhotos("a", { limit: 0 }).results).toHaveLength(1); + }); + + it("produces deterministic URLs for the same query", () => { + const a = searchPhotos("kitchen", { limit: 3 }); + const b = searchPhotos("kitchen", { limit: 3 }); + expect(a.results.map((r) => r.url)).toEqual(b.results.map((r) => r.url)); + }); + + it("differs across queries", () => { + const a = searchPhotos("kitchen", { limit: 3 }); + const b = searchPhotos("forest", { limit: 3 }); + expect(a.results[0].url).not.toBe(b.results[0].url); + }); + + it("uses picsum.photos host", () => { + const r = searchPhotos("home"); + for (const item of r.results) { + expect(item.url.startsWith("https://picsum.photos/")).toBe(true); + expect(item.source).toBe("picsum"); + } + }); +}); diff --git a/mcp-server/src/asset-fetchers/errors.js b/mcp-server/src/asset-fetchers/errors.js new file mode 100644 index 0000000..2ed1fc9 --- /dev/null +++ b/mcp-server/src/asset-fetchers/errors.js @@ -0,0 +1,13 @@ +/** + * Typed error thrown when a keyed provider was requested but the matching + * env var is unset. The orchestrator catches this to fall through to the + * next provider in the chain (Unsplash → Pexels → Picsum). + */ +export class MissingApiKeyError extends Error { + constructor(provider, envVar) { + super(`${envVar} is not set. Falling back to a keyless source.`); + this.name = "MissingApiKeyError"; + this.provider = provider; + this.envVar = envVar; + } +} diff --git a/mcp-server/src/asset-fetchers/index.js b/mcp-server/src/asset-fetchers/index.js new file mode 100644 index 0000000..af39469 --- /dev/null +++ b/mcp-server/src/asset-fetchers/index.js @@ -0,0 +1,79 @@ +/** + * Stock-image orchestrator. + * + * Implements the fallback chain Unsplash → Pexels → Picsum. When the agent + * picks an explicit `source`, we honour it and only fall back if the keyed + * provider's env var is unset (MissingApiKeyError). All other failures + * propagate up so the agent sees a real error message. + * + * The result envelope optionally carries a `warning` describing any + * silent fallback that occurred — agents can surface it to the user. + */ + +import * as unsplash from "./unsplash.js"; +import * as pexels from "./pexels.js"; +import * as picsum from "./picsum.js"; +import { MissingApiKeyError } from "./errors.js"; + +export { MissingApiKeyError }; + +const PROVIDERS = { + unsplash, + pexels, + picsum, +}; + +const DEFAULT_ORDER = ["unsplash", "pexels", "picsum"]; + +function hasKey(source) { + if (source === "unsplash") return !!process.env.UNSPLASH_ACCESS_KEY; + if (source === "pexels") return !!process.env.PEXELS_API_KEY; + if (source === "picsum") return true; + return false; +} + +/** + * @param {string} query + * @param {object} [opts] + * @param {"unsplash"|"pexels"|"picsum"} [opts.source] + * @param {number} [opts.limit=5] + * @returns {Promise<{results: Array, source: string, warning?: string}>} + */ +export async function findStockImage(query, opts = {}) { + if (typeof query !== "string" || query.trim() === "") { + throw new Error("query is required"); + } + const limit = Math.max(1, Math.min(20, Number.isFinite(opts.limit) ? opts.limit : 5)); + + // Determine order: if source is given, try it first, then fall through + // remaining providers in default order. Otherwise use default order. + const order = opts.source + ? [opts.source, ...DEFAULT_ORDER.filter((s) => s !== opts.source)] + : [...DEFAULT_ORDER]; + + const warnings = []; + for (const source of order) { + const provider = PROVIDERS[source]; + if (!provider) continue; + try { + const result = await Promise.resolve(provider.searchPhotos(query, { limit })); + const out = { ...result, source }; + if (warnings.length > 0) out.warning = warnings.join(" "); + return out; + } catch (err) { + if (err instanceof MissingApiKeyError) { + warnings.push( + `${err.envVar} is not set. Falling through to next provider.`, + ); + continue; + } + throw err; + } + } + + // Should be unreachable since picsum is keyless and never throws, + // but guard against it. + throw new Error("No stock-image provider succeeded"); +} + +export { hasKey }; diff --git a/mcp-server/src/asset-fetchers/pexels.js b/mcp-server/src/asset-fetchers/pexels.js new file mode 100644 index 0000000..d708424 --- /dev/null +++ b/mcp-server/src/asset-fetchers/pexels.js @@ -0,0 +1,72 @@ +/** + * Pexels provider. + * + * Hits https://api.pexels.com/v1/search with + * Authorization: + * + * Same env-only key handling as the Unsplash provider. + */ + +import { Cache } from "./cache.js"; +import { fetchJson } from "./http.js"; +import { MissingApiKeyError } from "./errors.js"; + +export const PEXELS_API_HOST = "api.pexels.com"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +const searchCache = new Cache({ + subdir: "image-search", + encoding: "json", + ttlMs: ONE_DAY_MS, +}); + +function getKey() { + const key = process.env.PEXELS_API_KEY; + if (!key) { + throw new MissingApiKeyError("pexels", "PEXELS_API_KEY"); + } + return key; +} + +/** + * @param {string} query + * @param {object} [opts] + * @param {number} [opts.limit=5] + * @returns {Promise<{results: Array}>} + */ +export async function searchPhotos(query, opts = {}) { + if (typeof query !== "string" || query.trim() === "") { + throw new Error("query is required"); + } + const limit = Math.max(1, Math.min(20, Number.isFinite(opts.limit) ? opts.limit : 5)); + const key = getKey(); + + const params = new URLSearchParams({ + query: query.trim(), + per_page: String(limit), + }); + const url = `https://${PEXELS_API_HOST}/v1/search?${params.toString()}`; + const cacheKey = `pexels|${query}|${limit}`; + + return searchCache.getOrFetch(cacheKey, async () => { + const json = await fetchJson(url, { + headers: { Authorization: key }, + }); + const items = Array.isArray(json?.photos) ? json.photos : []; + const results = items.map((p) => ({ + url: p?.src?.large || p?.src?.original || p?.src?.medium || "", + thumbnailUrl: p?.src?.small || p?.src?.tiny || p?.src?.medium || "", + alt: p?.alt || query, + attribution: p?.photographer + ? `Photo by ${p.photographer} on Pexels` + : "Pexels", + source: "pexels", + width: p?.width, + height: p?.height, + })); + return { results }; + }); +} + +export const _internal = { searchCache }; diff --git a/mcp-server/src/asset-fetchers/picsum.js b/mcp-server/src/asset-fetchers/picsum.js new file mode 100644 index 0000000..71b220b --- /dev/null +++ b/mcp-server/src/asset-fetchers/picsum.js @@ -0,0 +1,59 @@ +/** + * Picsum (Lorem Picsum) provider. + * + * Keyless. Picsum doesn't actually search — we generate deterministic + * seeded URLs so the same query always returns the same set of photos. + * Sizes vary across the result set so screens don't all use identical + * aspect ratios. This is the always-available fallback when no Unsplash + * or Pexels key is configured. + */ + +import { createHash } from "node:crypto"; + +export const PICSUM_HOST = "picsum.photos"; + +const DEFAULT_SIZES = [ + [1200, 800], + [1024, 768], + [800, 800], + [1200, 1600], + [1600, 1200], + [900, 1200], + [1280, 720], + [1080, 1080], +]; + +function slugify(query) { + // Lowercase, strip non-alphanumerics, hyphenate. Hash the result so + // long/empty queries still produce a stable seed. + const base = String(query).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + const hash = createHash("sha1").update(String(query)).digest("hex").slice(0, 8); + return base ? `${base}-${hash}` : hash; +} + +/** + * @param {string} query + * @param {object} [opts] + * @param {number} [opts.limit=5] + * @returns {{results: Array}} + */ +export function searchPhotos(query, opts = {}) { + const limit = Math.max(1, Math.min(20, Number.isFinite(opts.limit) ? opts.limit : 5)); + const seedBase = slugify(query); + + const results = []; + for (let i = 0; i < limit; i++) { + const [w, h] = DEFAULT_SIZES[i % DEFAULT_SIZES.length]; + const seed = `${seedBase}-${i}`; + results.push({ + url: `https://${PICSUM_HOST}/seed/${seed}/${w}/${h}`, + thumbnailUrl: `https://${PICSUM_HOST}/seed/${seed}/300/200`, + alt: `${query} #${i}`, + attribution: "Picsum (Lorem Picsum)", + source: "picsum", + width: w, + height: h, + }); + } + return { results }; +} diff --git a/mcp-server/src/asset-fetchers/unsplash.js b/mcp-server/src/asset-fetchers/unsplash.js new file mode 100644 index 0000000..a726815 --- /dev/null +++ b/mcp-server/src/asset-fetchers/unsplash.js @@ -0,0 +1,73 @@ +/** + * Unsplash provider. + * + * Hits https://api.unsplash.com/search/photos with + * Authorization: Client-ID + * + * The key is read from the env on each call — never logged, never persisted + * to disk, never stashed on this module's state. + */ + +import { Cache } from "./cache.js"; +import { fetchJson } from "./http.js"; +import { MissingApiKeyError } from "./errors.js"; + +export const UNSPLASH_API_HOST = "api.unsplash.com"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +const searchCache = new Cache({ + subdir: "image-search", + encoding: "json", + ttlMs: ONE_DAY_MS, +}); + +function getKey() { + const key = process.env.UNSPLASH_ACCESS_KEY; + if (!key) { + throw new MissingApiKeyError("unsplash", "UNSPLASH_ACCESS_KEY"); + } + return key; +} + +/** + * @param {string} query + * @param {object} [opts] + * @param {number} [opts.limit=5] + * @returns {Promise<{results: Array}>} + */ +export async function searchPhotos(query, opts = {}) { + if (typeof query !== "string" || query.trim() === "") { + throw new Error("query is required"); + } + const limit = Math.max(1, Math.min(20, Number.isFinite(opts.limit) ? opts.limit : 5)); + const key = getKey(); // throws MissingApiKeyError if env is unset + + const params = new URLSearchParams({ + query: query.trim(), + per_page: String(limit), + }); + const url = `https://${UNSPLASH_API_HOST}/search/photos?${params.toString()}`; + const cacheKey = `unsplash|${query}|${limit}`; + + return searchCache.getOrFetch(cacheKey, async () => { + const json = await fetchJson(url, { + headers: { Authorization: `Client-ID ${key}` }, + }); + const items = Array.isArray(json?.results) ? json.results : []; + const results = items.map((p) => ({ + url: p?.urls?.regular || p?.urls?.full || p?.urls?.raw || "", + thumbnailUrl: p?.urls?.small || p?.urls?.thumb || p?.urls?.regular || "", + alt: p?.alt_description || p?.description || query, + attribution: p?.user?.name + ? `Photo by ${p.user.name} on Unsplash` + : "Unsplash", + source: "unsplash", + width: p?.width, + height: p?.height, + })); + return { results }; + }); +} + +export const _internal = { searchCache }; From 32b626d375fd666bdf1fadac0eae8448dadae19e Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:22:52 +0700 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20MCP=20asset=20tools=20=E2=80=94=20g?= =?UTF-8?q?enerate=5Ficon,=20search=5Ficons,=20find=5Fstock=5Fimage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new MCP tools (net +3 → 32 tools total) backed by the new asset-fetchers infrastructure: - `generate_icon(collection, name, {size, color})` — Iconify SVG fetch. - `search_icons(query, {collection, limit})` — Iconify search. - `find_stock_image(query, {source, limit})` — orchestrated photo search (Unsplash → Pexels → Picsum) with `warning` on key-fallback. Per the implementation plan, `search_stock_images` was merged into `find_stock_image` — one tool, returns N results. Asset tools are stateless (no flow context required) so they bypass the `withFilePath` injection used by all other tool groups. --- mcp-server/src/server.js | 6 + .../src/tools/__tests__/asset-tools.test.js | 110 ++++++++++++++ mcp-server/src/tools/asset-tools.js | 140 ++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 mcp-server/src/tools/__tests__/asset-tools.test.js create mode 100644 mcp-server/src/tools/asset-tools.js diff --git a/mcp-server/src/server.js b/mcp-server/src/server.js index 5e87e4f..e8a615e 100644 --- a/mcp-server/src/server.js +++ b/mcp-server/src/server.js @@ -14,6 +14,7 @@ import { annotationTools, handleAnnotationTool } from "./tools/annotation-tools. 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"; const FILE_TOOL_NAMES = new Set(fileTools.map((t) => t.name)); const SCREEN_TOOL_NAMES = new Set(screenTools.map((t) => t.name)); @@ -25,6 +26,7 @@ const ANNOTATION_TOOL_NAMES = new Set(annotationTools.map((t) => t.name)); 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)); // filePath is injected into every non-file tool so callers can establish // session context inline (auto-loaded once, then reused for the whole session). @@ -58,6 +60,8 @@ const ALL_TOOLS = [ ...withFilePath(commentTools), ...withFilePath(generationTools), ...withFilePath(selectionTools), + // Asset tools (icons, stock photos) are stateless — no flow context required. + ...assetTools, ]; export function createServer(state, renderer, bridge) { @@ -102,6 +106,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 (ASSET_TOOL_NAMES.has(name)) { + result = await handleAssetTool(name, args, state); } else { return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }], diff --git a/mcp-server/src/tools/__tests__/asset-tools.test.js b/mcp-server/src/tools/__tests__/asset-tools.test.js new file mode 100644 index 0000000..2addd2d --- /dev/null +++ b/mcp-server/src/tools/__tests__/asset-tools.test.js @@ -0,0 +1,110 @@ +// @vitest-environment node + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { assetTools, handleAssetTool } from "../asset-tools.js"; +import { _internal as iconifyInternal } from "../../asset-fetchers/iconify.js"; + +function mockFetch(body, { ok = true, status = 200 } = {}) { + const res = { + ok, + status, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), + json: async () => (typeof body === "string" ? JSON.parse(body) : body), + headers: new Map(), + }; + global.fetch = vi.fn(async () => res); +} + +describe("assetTools tool definitions", () => { + it("declares 3 tools (icon-generate, icon-search, find-stock-image)", () => { + const names = assetTools.map((t) => t.name).sort(); + expect(names).toEqual(["find_stock_image", "generate_icon", "search_icons"]); + }); + + it("each tool has a non-empty description and input schema", () => { + for (const t of assetTools) { + expect(t.description.length).toBeGreaterThan(20); + expect(t.inputSchema.type).toBe("object"); + expect(t.inputSchema.properties).toBeTypeOf("object"); + } + }); +}); + +describe("handleAssetTool — generate_icon", () => { + beforeEach(() => { + iconifyInternal.svgCache.clearMemory(); + iconifyInternal.svgCache.readDisk = async () => null; + }); + + it("returns the SVG and echo of args", async () => { + mockFetch(""); + const out = await handleAssetTool( + "generate_icon", + { collection: "mdi", name: "home", size: 32 }, + {}, + ); + expect(out.svg).toContain(" { + await expect( + handleAssetTool("generate_icon", { name: "home" }, {}), + ).rejects.toThrow(/collection is required/); + await expect( + handleAssetTool("generate_icon", { collection: "mdi" }, {}), + ).rejects.toThrow(/name is required/); + }); +}); + +describe("handleAssetTool — search_icons", () => { + beforeEach(() => { + iconifyInternal.searchCache.clearMemory(); + iconifyInternal.searchCache.readDisk = async () => null; + }); + + it("returns normalized results", async () => { + mockFetch({ icons: ["mdi:home"] }); + const out = await handleAssetTool("search_icons", { query: "home" }, {}); + expect(out.total).toBe(1); + expect(out.results[0].id).toBe("mdi:home"); + }); + + it("requires query", async () => { + await expect( + handleAssetTool("search_icons", {}, {}), + ).rejects.toThrow(/query is required/); + }); +}); + +describe("handleAssetTool — find_stock_image (zero config)", () => { + it("falls back to Picsum with warning when no keys are set", async () => { + delete process.env.UNSPLASH_ACCESS_KEY; + delete process.env.PEXELS_API_KEY; + const out = await handleAssetTool( + "find_stock_image", + { query: "kitchen", limit: 2 }, + {}, + ); + expect(out.source).toBe("picsum"); + expect(out.results).toHaveLength(2); + expect(out.warning).toBeTruthy(); + }); + + it("requires query", async () => { + await expect( + handleAssetTool("find_stock_image", {}, {}), + ).rejects.toThrow(/query is required/); + }); +}); + +describe("handleAssetTool — unknown tool", () => { + it("throws", async () => { + await expect( + handleAssetTool("nope", {}, {}), + ).rejects.toThrow(/Unknown asset tool/); + }); +}); diff --git a/mcp-server/src/tools/asset-tools.js b/mcp-server/src/tools/asset-tools.js new file mode 100644 index 0000000..a4952bb --- /dev/null +++ b/mcp-server/src/tools/asset-tools.js @@ -0,0 +1,140 @@ +/** + * Asset tools — icon and stock-photo discovery for the Drawd MCP. + * + * Three tools: + * - generate_icon Fetch one Iconify icon by collection + name (SVG). + * - search_icons Search Iconify and return ranked candidate IDs. + * - find_stock_image Search photos via Unsplash/Pexels/Picsum chain. + * + * Per the implementation plan, search_stock_images was merged into + * find_stock_image — one tool, returning N results. + */ + +import { + fetchIcon, + searchIcons as iconifySearch, + RECOMMENDED_COLLECTIONS, +} from "../asset-fetchers/iconify.js"; +import { findStockImage } from "../asset-fetchers/index.js"; + +export const assetTools = [ + { + name: "generate_icon", + description: + "Fetch an icon from Iconify (275k+ icons across collections like " + + RECOMMENDED_COLLECTIONS.join(", ") + + ") and return it as an inline SVG string. Embed the returned SVG verbatim in the screen HTML before calling create_screen — Satori renders SVG natively. Use color='currentColor' to inherit text color from the surrounding HTML.", + inputSchema: { + type: "object", + properties: { + collection: { + type: "string", + description: + "Iconify collection prefix. Recommended: " + + RECOMMENDED_COLLECTIONS.join(", ") + + ". Slugs are lowercase letters/digits/hyphens only.", + }, + name: { + type: "string", + description: + "Icon name within the collection (e.g. 'home', 'chevron-right').", + }, + size: { + type: "number", + description: "Width/height in CSS pixels. Default 24.", + }, + color: { + type: "string", + description: + "CSS color (hex, rgb, or 'currentColor'). Default 'currentColor'.", + }, + }, + required: ["collection", "name"], + }, + }, + { + name: "search_icons", + description: + "Search Iconify across all (or one) collections for icons matching a query. Returns ranked candidate icon IDs. Pick one and pass it to generate_icon to fetch the SVG.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query (e.g. 'hamburger menu')." }, + collection: { + type: "string", + description: + "Optional. Restrict search to one collection (e.g. 'mdi').", + }, + limit: { + type: "number", + description: "Max results. Default 12, max 64.", + }, + }, + required: ["query"], + }, + }, + { + name: "find_stock_image", + description: + "Search royalty-free photos from Unsplash, Pexels, or Picsum and return URLs with attribution. Embed via in screen HTML; the renderer will fetch and inline the bytes at PNG-render time. With no API keys configured, falls back to Picsum (deterministic seeded photos — note Picsum does not actually search by query). Set UNSPLASH_ACCESS_KEY and/or PEXELS_API_KEY env vars for query-relevant results.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Photo search query (e.g. 'modern kitchen')." }, + source: { + type: "string", + enum: ["unsplash", "pexels", "picsum"], + description: + "Optional. If omitted, tries unsplash → pexels → picsum based on configured keys. Picks the first provider that has a key; Picsum is always available.", + }, + limit: { + type: "number", + description: "Max results. Default 5, max 20.", + }, + }, + required: ["query"], + }, + }, +]; + +export async function handleAssetTool(name, args, _state) { + switch (name) { + case "generate_icon": { + if (!args?.collection) throw new Error("collection is required"); + if (!args?.name) throw new Error("name is required"); + const size = Number.isFinite(args.size) ? args.size : 24; + const color = typeof args.color === "string" && args.color + ? args.color + : "currentColor"; + const svg = await fetchIcon(args.collection, args.name, { size, color }); + return { + svg, + collection: args.collection, + name: args.name, + size, + color, + }; + } + + case "search_icons": { + if (!args?.query) throw new Error("query is required"); + const { results, total } = await iconifySearch(args.query, { + prefix: args.collection, + limit: args.limit, + }); + return { results, total }; + } + + case "find_stock_image": { + if (!args?.query) throw new Error("query is required"); + const out = await findStockImage(args.query, { + source: args.source, + limit: args.limit, + }); + return out; + } + + default: + throw new Error(`Unknown asset tool: ${name}`); + } +} From 1b6c0646a1d3a89fae996b7012a22eed318bcfb1 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:23:02 +0700 Subject: [PATCH 5/6] feat: renderer pre-pass to inline remote URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Satori cannot fetch image URLs itself, so screens that reference stock photos via `` would otherwise render with broken images. This pre-pass downloads each unique image URL and rewrites `src` to a base64 data URI before Satori parses the HTML. Concurrency is capped at 4 in-flight downloads. The image-bytes cache is the same three-tier cache used by the other asset fetchers, so re-renders are fast. SECURITY: an explicit hostname allowlist is enforced — only the provider hosts the asset tools emit (api.iconify.design, images.unsplash.com, api.unsplash.com, api.pexels.com, images.pexels.com, picsum.photos, fastly.picsum.photos) are fetched. Any other host (or a failed/timed-out fetch) is replaced with a transparent 1×1 PNG so prompt-injected `` cannot turn the MCP into an SSRF gadget and a single bad URL never breaks the whole render. --- .../renderer/__tests__/inline-images.test.js | 119 ++++++++++++++ mcp-server/src/renderer/satori-renderer.js | 154 +++++++++++++++++- 2 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 mcp-server/src/renderer/__tests__/inline-images.test.js diff --git a/mcp-server/src/renderer/__tests__/inline-images.test.js b/mcp-server/src/renderer/__tests__/inline-images.test.js new file mode 100644 index 0000000..aad3e6e --- /dev/null +++ b/mcp-server/src/renderer/__tests__/inline-images.test.js @@ -0,0 +1,119 @@ +// @vitest-environment node + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { inlineRemoteImages, _imageInlineInternals } from "../satori-renderer.js"; +import { Cache } from "../../asset-fetchers/cache.js"; + +function makeFreshCache() { + const c = new Cache({ subdir: `inline-images-test-${Math.random().toString(36).slice(2, 8)}`, encoding: "binary" }); + c.readDisk = async () => null; + c.writeDisk = async () => {}; + return c; +} + +function mockFetchBinary(byteValue, contentType) { + const arrayBuffer = new Uint8Array([byteValue]).buffer; + const res = { + ok: true, + status: 200, + arrayBuffer: async () => arrayBuffer, + headers: { get: (k) => (k.toLowerCase() === "content-type" ? contentType : null) }, + }; + global.fetch = vi.fn(async () => res); +} + +describe("inlineRemoteImages — passthrough", () => { + it("returns input unchanged when no tags exist", async () => { + const html = "
no images here
"; + expect(await inlineRemoteImages(html)).toBe(html); + }); + + it("returns input unchanged for non-string input", async () => { + expect(await inlineRemoteImages(undefined)).toBeUndefined(); + }); +}); + +describe("inlineRemoteImages — allowlist enforcement", () => { + beforeEach(() => { + _imageInlineInternals.imageBytesCache.clearMemory(); + }); + + it("replaces disallowed-host with transparent PNG", async () => { + const cache = makeFreshCache(); + global.fetch = vi.fn(); + const html = ''; + const out = await inlineRemoteImages(html, cache); + expect(out).toContain(_imageInlineInternals.TRANSPARENT_PNG_DATA_URI); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("replaces non-http(s) URLs with transparent PNG", 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); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +describe("inlineRemoteImages — happy path", () => { + beforeEach(() => { + _imageInlineInternals.imageBytesCache.clearMemory(); + }); + + it("downloads and inlines an allowlisted picsum URL", 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); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("dedupes multiple references to the same URL", async () => { + const cache = makeFreshCache(); + mockFetchBinary(0x42, "image/jpeg"); + const url = "https://images.unsplash.com/photo-abc"; + const html = `
`; + await inlineRemoteImages(html, cache); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("falls back to transparent PNG 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); + }); + + it("handles a mix of allowed and disallowed URLs", async () => { + const cache = makeFreshCache(); + let call = 0; + global.fetch = vi.fn(async () => { + call++; + return { + ok: true, + status: 200, + arrayBuffer: async () => new Uint8Array([call]).buffer, + headers: { get: () => "image/png" }, + }; + }); + const html = + '
' + + '' + + '' + + '
'; + const out = await inlineRemoteImages(html, cache); + expect(out).toContain("data:image/png;base64,"); + expect(out).toContain(_imageInlineInternals.TRANSPARENT_PNG_DATA_URI); + // 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 6364829..79a3143 100644 --- a/mcp-server/src/renderer/satori-renderer.js +++ b/mcp-server/src/renderer/satori-renderer.js @@ -2,6 +2,8 @@ import { readFileSync } from "node:fs"; import { resolveViewport, DEVICE_PRESETS } from "./device-presets.js"; import { getEmojiCode, loadEmojiSvg } from "./emoji-loader.js"; import { composeChromeSvg, expandAutoChrome } from "./chrome/index.js"; +import { Cache } from "../asset-fetchers/cache.js"; +import { fetchBinary } from "../asset-fetchers/http.js"; // Dynamic imports resolved at runtime to support esbuild bundling let _satori = null; @@ -36,6 +38,150 @@ function resolveAssetsDir() { const ASSETS_DIR = resolveAssetsDir(); +// ── Remote image inlining ───────────────────────────────────────────────────── +// +// Satori cannot fetch image URLs itself — the renderer pre-pass downloads +// each referenced in the HTML and rewrites the src +// to a base64 data URI. Failures fall back to a transparent 1×1 PNG so a +// single bad URL never breaks the whole render. +// +// SECURITY: only hosts on this allowlist are fetched. Any other src= URL +// is replaced with the transparent placeholder. This prevents prompt-injected +// HTML (e.g. an attacker-supplied ``) +// from causing the MCP to make arbitrary outbound requests. + +const ALLOWED_IMAGE_HOSTS = new Set([ + "api.iconify.design", + "images.unsplash.com", + "api.unsplash.com", + "api.pexels.com", + "images.pexels.com", + "picsum.photos", + "fastly.picsum.photos", +]); + +const TRANSPARENT_PNG_DATA_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII="; + +const _imageBytesCache = new Cache({ + subdir: "images", + encoding: "binary", + ttlMs: 30 * 24 * 60 * 60 * 1000, // 30 days +}); + +const IMG_TAG_RE = /]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+)["'][^>]*>/gi; + +function isAllowedImageHost(rawUrl) { + try { + const u = new URL(rawUrl); + if (u.protocol !== "https:" && u.protocol !== "http:") return false; + return ALLOWED_IMAGE_HOSTS.has(u.hostname.toLowerCase()); + } catch { + return false; + } +} + +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Replace every literal `src=""` and `src=''` for `url` with a + * `src=""` data URI. We rebuild the regex per-URL rather than + * doing one pass over the whole document so we can keep the URL-to-data-URI + * mapping cleanly per-unique URL. + */ +function swapSrc(html, url, replacement) { + const re = new RegExp( + `src\\s*=\\s*["']${escapeRegex(url)}["']`, + "g", + ); + return html.replace(re, `src="${replacement}"`); +} + +/** + * Pre-process HTML by downloading and inlining any remote `` URLs + * that point at our allowlisted hosts. Anything outside the allowlist or + * any failed fetch is replaced with a transparent 1×1 PNG. + * + * Concurrency is capped at 4 in-flight downloads. + * + * @param {string} html + * @param {Cache} [imageCache] Override the module-level cache (mostly for tests). + * @returns {Promise} + */ +export async function inlineRemoteImages(html, imageCache = _imageBytesCache) { + if (typeof html !== "string" || !html.includes(" m[1]))]; + + // Resolve each unique URL to a data URI. Cap concurrency at 4. + const results = new Map(); + const queue = [...uniqueUrls]; + 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)); + } + }); + await Promise.all(workers); + + let out = html; + for (const [url, dataUri] of results) { + out = swapSrc(out, url, dataUri); + } + return out; +} + +async function resolveImageToDataUri(url, imageCache) { + if (!isAllowedImageHost(url)) { + process.stderr.write( + `[drawd-mcp] Rejecting outside allowlist: ${safeHostFor(url)}\n`, + ); + return TRANSPARENT_PNG_DATA_URI; + } + try { + const cached = await imageCache.getOrFetch(url, async () => { + const { bytes, contentType } = await fetchBinary(url); + // Pack bytes + content-type into a single Buffer for binary cache. + // Format: [4-byte BE length of contentType][contentType][bytes] + const ctBuf = Buffer.from(contentType, "utf8"); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32BE(ctBuf.length, 0); + return Buffer.concat([lenBuf, ctBuf, bytes]); + }); + if (!Buffer.isBuffer(cached) || cached.length < 4) return TRANSPARENT_PNG_DATA_URI; + 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")}`; + } catch (err) { + process.stderr.write( + `[drawd-mcp] Failed to inline ${safeHostFor(url)}: ${err.message}\n`, + ); + return TRANSPARENT_PNG_DATA_URI; + } +} + +function safeHostFor(url) { + try { + return new URL(url).hostname; + } catch { + return "(unparseable url)"; + } +} + +export const _imageInlineInternals = { + ALLOWED_IMAGE_HOSTS, + TRANSPARENT_PNG_DATA_URI, + imageBytesCache: _imageBytesCache, +}; + +// ────────────────────────────────────────────────────────────────────────────── + export class SatoriRenderer { constructor() { this.fonts = null; @@ -86,11 +232,17 @@ export class SatoriRenderer { // chrome only applies when we know which device we're targeting). const resolvedDevice = (width && height) ? null : (device || "iphone"); + // Inline any remote URLs into base64 data URIs + // 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(htmlString); + // satori-html does not decode HTML entities. Agents frequently write // numeric entities (●, ●) and safe named entities (•, // …) — decode them before parsing so they render as glyphs, not // literal text. - const decodedHtml = decodeSafeEntities(htmlString); + const decodedHtml = decodeSafeEntities(inlinedHtml); // Wrap bare content in a full-page container if needed const wrappedHtml = ensureRootContainer(decodedHtml, viewport.width, viewport.height); From bd4544f84581df9285985b9ccd1a240a8d2d36b4 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:23:10 +0700 Subject: [PATCH 6/6] feat: document MCP icon and stock-image tools - userGuide.md gains an "Icons and stock photos" section under MCP usage, describing the 3 new tools, the zero-config / key-upgrade paths, and the renderer's image-inlining + hostname allowlist behaviour. - Tool count bumped from 29 to 32. New "Assets" category added. - mcp-server/index.js gains a header comment listing the new UNSPLASH_ACCESS_KEY / PEXELS_API_KEY env vars alongside existing args with an explicit "never logged, never persisted" reminder. --- mcp-server/index.js | 19 +++++++++++++++++++ src/pages/docs/userGuide.md | 30 +++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/mcp-server/index.js b/mcp-server/index.js index 33ff0b5..dc31360 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -1,3 +1,22 @@ +// Drawd MCP server entry point. +// +// CLI args: +// --file Pre-load a flow file at startup. Equivalent to the +// agent calling open_flow as its first tool. +// +// Environment variables (all optional): +// UNSPLASH_ACCESS_KEY Enables query-relevant Unsplash photos in +// find_stock_image. Without it, the tool falls back +// to Pexels (if PEXELS_API_KEY is set) or Picsum. +// PEXELS_API_KEY Enables Pexels as the secondary photo source. +// DRAWD_SELECTION_PORT Override the localhost port the selection bridge +// binds to. Defaults to 3337. +// CHROME_PATH Path to Chrome/Chromium for the legacy html-to-png +// renderer. Not used by the default Satori path. +// +// API keys are read from env on every call, never logged, never written +// to disk. Outbound asset fetches are restricted to a hostname allowlist — +// see src/renderer/satori-renderer.js (inlineRemoteImages). import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { FlowState } from "./src/state.js"; import { SatoriRenderer } from "./src/renderer/satori-renderer.js"; diff --git a/src/pages/docs/userGuide.md b/src/pages/docs/userGuide.md index b008fa3..4886eaa 100644 --- a/src/pages/docs/userGuide.md +++ b/src/pages/docs/userGuide.md @@ -832,7 +832,7 @@ Open Claude Desktop settings, go to the MCP section, and add a new server with: ### Available tools -The MCP server exposes 29 tools organized by category: +The MCP server exposes 32 tools organized by category: - **File** — `create_flow`, `open_flow`, `save_flow`, `get_flow_info` - **Screen** — `create_screen` (from HTML), `create_blank_screen`, `update_screen`, `delete_screen`, `list_screens`, `get_screen`, `update_screen_image`, `batch_create_screens`, `compose_chrome`, `get_chrome_info` @@ -844,6 +844,7 @@ The MCP server exposes 29 tools organized by category: - **Comments** — `list_comments`, `create_comment`, `update_comment`, `resolve_comment`, `delete_comment` - **Generation** — `validate_flow`, `generate_instructions`, `analyze_navigation` - **Selection** — `get_current_selection` +- **Assets** — `generate_icon`, `search_icons`, `find_stock_image` ### Creating screens from HTML @@ -894,6 +895,33 @@ A typical agent interaction looks like this: You can then open the saved `.drawd` file in Drawd to visually inspect the flow, adjust screen positions, refine hotspots, and regenerate instructions. +### Icons and stock photos + +Three asset tools let agents enrich screens with real imagery instead of hand-drawn shapes or emoji substitutes. + +- `generate_icon` — Fetch one icon by `collection` + `name` from Iconify (275k+ icons across `mdi`, `ph`, `lucide`, `tabler`, `heroicons`, `solar`, `carbon`, and more). Returns an inline SVG string the agent embeds verbatim in the screen HTML. Use `color: "currentColor"` to inherit the surrounding text color. +- `search_icons` — Search Iconify across all (or one) collections. Returns ranked candidate icon IDs the agent can preview and pick from before calling `generate_icon`. +- `find_stock_image` — Search royalty-free photos and get back URLs with attribution. Embed via `` in the screen HTML; the renderer fetches and inlines the bytes at PNG-render time. Sources: Unsplash, Pexels, Picsum. + +#### Zero-config and key-upgrade paths + +The tools work on a fresh install with no API keys — Iconify and Picsum are keyless. Setting environment variables upgrades the photo source without code changes: + +- `UNSPLASH_ACCESS_KEY` — enables query-relevant Unsplash results. +- `PEXELS_API_KEY` — enables Pexels as the secondary photo source. + +`find_stock_image` tries `unsplash` → `pexels` → `picsum` in order based on which keys are configured. When a keyed source is requested but the key is missing, the tool transparently falls back to the next provider and includes a `warning` field describing what happened, so the agent can surface it to the user. + +> [!NOTE] +> Picsum does not actually search by query — it returns deterministic seeded photos for a given query string. Set `UNSPLASH_ACCESS_KEY` for query-relevant results. + +#### Renderer image inlining + +When `create_screen` HTML contains ``, the renderer downloads each image and bakes the bytes into the rendered PNG so the screen looks complete. Only an allowlist of provider hosts (`api.iconify.design`, `images.unsplash.com`, `api.unsplash.com`, `api.pexels.com`, `images.pexels.com`, `picsum.photos`, `fastly.picsum.photos`) is fetched. Any other host (or a failed fetch) is replaced with a transparent 1×1 placeholder so a single bad URL never breaks the whole render. + +> [!TIP] +> Cached image bytes live under `~/.cache/drawd-mcp/` and persist across runs. The first render of a screen with photos is the slow one; re-renders use the disk cache. + ### Reading the user's current selection The `get_current_selection` tool lets an AI agent know which element(s) you currently have selected in the Drawd browser app — so you can say "update this screen" or "add a hotspot here" without pasting IDs.