Skip to content

feat: MCP icon and stock-image tools (zero-config + key fallback)#48

Merged
trmquang93 merged 6 commits intomainfrom
feat/mcp-icons-stock-images
Apr 27, 2026
Merged

feat: MCP icon and stock-image tools (zero-config + key fallback)#48
trmquang93 merged 6 commits intomainfrom
feat/mcp-icons-stock-images

Conversation

@trmquang93
Copy link
Copy Markdown
Collaborator

Summary

Three new MCP tools and a renderer pre-pass that lets agents enrich screens with real icons and photos instead of hand-drawn shapes or emoji substitutes:

  • generate_icon(collection, name, {size, color}) — Iconify SVG fetch (275k+ icons across mdi, ph, lucide, tabler, heroicons, solar, carbon, …). Returns inline SVG the agent embeds verbatim in screen HTML.
  • search_icons(query, {collection, limit}) — Iconify search returning ranked candidate IDs.
  • find_stock_image(query, {source, limit}) — orchestrated photo search with a graceful fallback chain: Unsplash → Pexels → Picsum.

Tool count: 29 → 32. Per the implementation plan, find_stock_image and search_stock_images were merged into a single tool.

Renderer pre-pass

<img src="https://..."> URLs in create_screen HTML are now downloaded and inlined as base64 data URIs before Satori parses the HTML. Concurrency capped at 4. Failed/timeout fetches fall back to a transparent 1×1 PNG so a single bad URL never breaks the whole render.

Security: an explicit hostname allowlist is enforced — only provider hosts (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 is replaced with the transparent placeholder so prompt-injected <img src="https://attacker.example/..."> cannot turn the MCP into an SSRF gadget.

Env-var setup

Both keys are optional — the tools work zero-config on a fresh install (Iconify + Picsum paths only). Set either to upgrade quality without code changes:

  • UNSPLASH_ACCESS_KEY — enables query-relevant Unsplash photos.
  • PEXELS_API_KEY — enables Pexels as the secondary photo source.

API keys are read from env on every call — never logged, never persisted to disk, never stashed on module state.

Caching

All caches live under ~/.cache/drawd-mcp/:

  • icons/ — Iconify SVGs (no TTL — icons are immutable per id)
  • icon-search/ — Iconify search results (7-day TTL)
  • images/ — inlined photo bytes (30-day TTL)
  • image-search/ — stock photo search results (1-day TTL)

Zero new runtime npm dependencies — native fetch only.

Test plan

  • Unit tests for cache (in-flight dedup, miss/hit, error recovery)
  • Unit tests for Iconify provider (URL construction, slug validation, not-found handling, cache hit)
  • Unit tests for Picsum (deterministic seeding, host check, limit clamping)
  • Unit tests for orchestrator (full fallback chain, explicit-source override, key-fallback warning, auth header construction)
  • Unit tests for asset-tool handlers (arg validation, result shape, warning bubbles up)
  • Renderer inline-images tests (passthrough, allowlist enforcement, dedup, fetch-failure fallback, mixed allowed/disallowed URLs)
  • Full suite (npm test) — 834/834 passing
  • npm run lint — clean
  • npm run build — production bundle builds
  • mcp-server/ bundled with esbuild — boots cleanly via npm start
  • Manual: zero-config Picsum path (no env vars)
  • Manual: with UNSPLASH_ACCESS_KEY set, find_stock_image returns query-relevant results
  • Manual: with PEXELS_API_KEY only, find_stock_image source="unsplash" warns and falls back
  • Manual: render screen with <img src="https://attacker.example/track.gif"> — pre-pass rejects, render produces transparent placeholder
  • Manual: render screen with 5+ Unsplash photos — parallel fetch works, output PNG carries all images

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.
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 `<svg></svg>`
"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.
…chain

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.
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.
Satori cannot fetch image URLs itself, so screens that reference
stock photos via `<img src="https://...">` 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 `<img src="https://attacker.example/...">` cannot
turn the MCP into an SSRF gadget and a single bad URL never breaks
the whole render.
- 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.
@trmquang93 trmquang93 merged commit 6119ea8 into main Apr 27, 2026
1 check passed
@trmquang93 trmquang93 deleted the feat/mcp-icons-stock-images branch April 27, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant