Skip to content

Commit 6119ea8

Browse files
authored
feat: MCP icon and stock-image tools (zero-config + key fallback) (#48)
* feat: shared HTTP + three-tier cache for MCP asset fetchers 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. * 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 `<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. * feat: stock-photo providers (Unsplash, Pexels, Picsum) with fallback 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. * feat: MCP asset tools — generate_icon, search_icons, find_stock_image 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. * feat: renderer pre-pass to inline remote <img src> URLs 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. * 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. --------- Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent ee5fcc8 commit 6119ea8

19 files changed

Lines changed: 1544 additions & 2 deletions

File tree

mcp-server/index.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
// Drawd MCP server entry point.
2+
//
3+
// CLI args:
4+
// --file <path.drawd> Pre-load a flow file at startup. Equivalent to the
5+
// agent calling open_flow as its first tool.
6+
//
7+
// Environment variables (all optional):
8+
// UNSPLASH_ACCESS_KEY Enables query-relevant Unsplash photos in
9+
// find_stock_image. Without it, the tool falls back
10+
// to Pexels (if PEXELS_API_KEY is set) or Picsum.
11+
// PEXELS_API_KEY Enables Pexels as the secondary photo source.
12+
// DRAWD_SELECTION_PORT Override the localhost port the selection bridge
13+
// binds to. Defaults to 3337.
14+
// CHROME_PATH Path to Chrome/Chromium for the legacy html-to-png
15+
// renderer. Not used by the default Satori path.
16+
//
17+
// API keys are read from env on every call, never logged, never written
18+
// to disk. Outbound asset fetches are restricted to a hostname allowlist —
19+
// see src/renderer/satori-renderer.js (inlineRemoteImages).
120
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
221
import { FlowState } from "./src/state.js";
322
import { SatoriRenderer } from "./src/renderer/satori-renderer.js";
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// @vitest-environment node
2+
//
3+
// Cache uses real fs/promises, so use the node env (avoid jsdom intercepting node: imports).
4+
5+
import { describe, it, expect, beforeEach, vi } from "vitest";
6+
import { Cache, hashKey } from "../cache.js";
7+
8+
const uniqueSubdir = () =>
9+
`test-cache-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10+
11+
describe("hashKey", () => {
12+
it("produces a stable hex string", () => {
13+
expect(hashKey("foo")).toMatch(/^[0-9a-f]{40}$/);
14+
expect(hashKey("foo")).toBe(hashKey("foo"));
15+
expect(hashKey("foo")).not.toBe(hashKey("bar"));
16+
});
17+
});
18+
19+
describe("Cache", () => {
20+
let cache;
21+
beforeEach(() => {
22+
cache = new Cache({ subdir: uniqueSubdir(), encoding: "text" });
23+
});
24+
25+
it("caches the fetcher result in memory after first call", async () => {
26+
const fetcher = vi.fn(async () => "value-1");
27+
const r1 = await cache.getOrFetch("k", fetcher);
28+
const r2 = await cache.getOrFetch("k", fetcher);
29+
expect(r1).toBe("value-1");
30+
expect(r2).toBe("value-1");
31+
expect(fetcher).toHaveBeenCalledTimes(1);
32+
});
33+
34+
it("dedupes concurrent in-flight requests", async () => {
35+
let resolveOuter;
36+
const fetcher = vi.fn(
37+
() =>
38+
new Promise((resolve) => {
39+
resolveOuter = resolve;
40+
})
41+
);
42+
// disable disk reads so the in-flight slot is set synchronously enough
43+
cache.readDisk = async () => null;
44+
const p1 = cache.getOrFetch("dup", fetcher);
45+
const p2 = cache.getOrFetch("dup", fetcher);
46+
// Yield microtasks so the readDisk(null) await resolves and the fetcher fires.
47+
await Promise.resolve();
48+
await Promise.resolve();
49+
expect(fetcher).toHaveBeenCalledTimes(1);
50+
resolveOuter("dup-value");
51+
const [v1, v2] = await Promise.all([p1, p2]);
52+
expect(v1).toBe("dup-value");
53+
expect(v2).toBe("dup-value");
54+
});
55+
56+
it("falls back to fetcher again after clearMemory", async () => {
57+
const fetcher = vi
58+
.fn()
59+
.mockResolvedValueOnce("v1")
60+
.mockResolvedValueOnce("v2");
61+
62+
const r1 = await cache.getOrFetch("k", fetcher);
63+
cache.clearMemory();
64+
// disk write is fire-and-forget; it may or may not have landed before
65+
// the next call. To make this test deterministic, override readDisk.
66+
cache.readDisk = async () => null;
67+
const r2 = await cache.getOrFetch("k", fetcher);
68+
expect(r1).toBe("v1");
69+
expect(r2).toBe("v2");
70+
expect(fetcher).toHaveBeenCalledTimes(2);
71+
});
72+
73+
it("propagates fetcher errors and removes the in-flight slot", async () => {
74+
const err = new Error("boom");
75+
const fetcher = vi
76+
.fn()
77+
.mockRejectedValueOnce(err)
78+
.mockResolvedValueOnce("recovered");
79+
80+
await expect(cache.getOrFetch("err", fetcher)).rejects.toThrow("boom");
81+
cache.readDisk = async () => null;
82+
const r2 = await cache.getOrFetch("err", fetcher);
83+
expect(r2).toBe("recovered");
84+
});
85+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// @vitest-environment node
2+
3+
import { describe, it, expect, beforeEach, vi } from "vitest";
4+
import { fetchIcon, searchIcons, _internal } from "../iconify.js";
5+
6+
function mockFetchOnce(body, { ok = true, status = 200 } = {}) {
7+
const res = {
8+
ok,
9+
status,
10+
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
11+
json: async () => (typeof body === "string" ? JSON.parse(body) : body),
12+
headers: new Map(),
13+
};
14+
global.fetch = vi.fn(async () => res);
15+
}
16+
17+
describe("iconify.fetchIcon", () => {
18+
beforeEach(() => {
19+
_internal.svgCache.clearMemory();
20+
// disable disk reads so tests don't depend on fs state
21+
_internal.svgCache.readDisk = async () => null;
22+
});
23+
24+
it("constructs the iconify URL with size and color params", async () => {
25+
mockFetchOnce("<svg xmlns=\"http://www.w3.org/2000/svg\"><path/></svg>");
26+
await fetchIcon("mdi", "home", { size: 32, color: "#ff0000" });
27+
const calledUrl = global.fetch.mock.calls[0][0];
28+
expect(calledUrl).toContain("https://api.iconify.design/mdi/home.svg");
29+
expect(calledUrl).toContain("height=32");
30+
expect(calledUrl).toContain("color=%23ff0000");
31+
});
32+
33+
it("rejects unsafe collection slugs", async () => {
34+
await expect(fetchIcon("../etc", "home")).rejects.toThrow(/Invalid iconify collection/);
35+
});
36+
37+
it("rejects unsafe icon names", async () => {
38+
await expect(fetchIcon("mdi", "home/passwd")).rejects.toThrow(/Invalid iconify icon/);
39+
});
40+
41+
it("rejects empty SVG bodies as not-found", async () => {
42+
mockFetchOnce("<svg></svg>");
43+
await expect(fetchIcon("mdi", "missing")).rejects.toThrow(/Icon not found/);
44+
});
45+
46+
it("returns the SVG body verbatim on success", async () => {
47+
const body = "<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"4\"/></svg>";
48+
mockFetchOnce(body);
49+
const out = await fetchIcon("mdi", "home");
50+
expect(out).toBe(body);
51+
});
52+
53+
it("uses cache on second lookup", async () => {
54+
const body = "<svg xmlns=\"http://www.w3.org/2000/svg\"><path/></svg>";
55+
mockFetchOnce(body);
56+
await fetchIcon("mdi", "home");
57+
await fetchIcon("mdi", "home");
58+
expect(global.fetch).toHaveBeenCalledTimes(1);
59+
});
60+
});
61+
62+
describe("iconify.searchIcons", () => {
63+
beforeEach(() => {
64+
_internal.searchCache.clearMemory();
65+
_internal.searchCache.readDisk = async () => null;
66+
});
67+
68+
it("returns normalized {results,total} from iconify response", async () => {
69+
mockFetchOnce({ icons: ["mdi:home", "ph:house"] });
70+
const out = await searchIcons("home");
71+
expect(out.total).toBe(2);
72+
expect(out.results).toEqual([
73+
{ id: "mdi:home", collection: "mdi", name: "home" },
74+
{ id: "ph:house", collection: "ph", name: "house" },
75+
]);
76+
});
77+
78+
it("clamps limit between 1 and 64", async () => {
79+
mockFetchOnce({ icons: [] });
80+
await searchIcons("home", { limit: 9999 });
81+
const calledUrl = global.fetch.mock.calls[0][0];
82+
expect(calledUrl).toContain("limit=64");
83+
});
84+
85+
it("requires a non-empty query", async () => {
86+
await expect(searchIcons("")).rejects.toThrow(/query is required/);
87+
});
88+
89+
it("passes prefix when provided", async () => {
90+
mockFetchOnce({ icons: [] });
91+
await searchIcons("home", { prefix: "mdi" });
92+
const calledUrl = global.fetch.mock.calls[0][0];
93+
expect(calledUrl).toContain("prefix=mdi");
94+
});
95+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// @vitest-environment node
2+
3+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
4+
import { findStockImage } from "../index.js";
5+
import { _internal as unsplashInternal } from "../unsplash.js";
6+
import { _internal as pexelsInternal } from "../pexels.js";
7+
8+
const ORIG_UNSPLASH = process.env.UNSPLASH_ACCESS_KEY;
9+
const ORIG_PEXELS = process.env.PEXELS_API_KEY;
10+
11+
function mockFetchJson(body) {
12+
const res = {
13+
ok: true,
14+
status: 200,
15+
json: async () => body,
16+
text: async () => JSON.stringify(body),
17+
headers: new Map(),
18+
};
19+
global.fetch = vi.fn(async () => res);
20+
}
21+
22+
beforeEach(() => {
23+
delete process.env.UNSPLASH_ACCESS_KEY;
24+
delete process.env.PEXELS_API_KEY;
25+
unsplashInternal.searchCache.clearMemory();
26+
unsplashInternal.searchCache.readDisk = async () => null;
27+
unsplashInternal.searchCache.writeDisk = async () => {};
28+
pexelsInternal.searchCache.clearMemory();
29+
pexelsInternal.searchCache.readDisk = async () => null;
30+
pexelsInternal.searchCache.writeDisk = async () => {};
31+
});
32+
33+
afterEach(() => {
34+
if (ORIG_UNSPLASH != null) process.env.UNSPLASH_ACCESS_KEY = ORIG_UNSPLASH;
35+
if (ORIG_PEXELS != null) process.env.PEXELS_API_KEY = ORIG_PEXELS;
36+
});
37+
38+
describe("findStockImage — fallback chain", () => {
39+
it("falls all the way through to Picsum when no keys are set", async () => {
40+
const out = await findStockImage("kitchen", { limit: 3 });
41+
expect(out.source).toBe("picsum");
42+
expect(out.results.length).toBe(3);
43+
expect(out.warning).toMatch(/UNSPLASH_ACCESS_KEY/);
44+
expect(out.warning).toMatch(/PEXELS_API_KEY/);
45+
});
46+
47+
it("uses unsplash when UNSPLASH_ACCESS_KEY is set", async () => {
48+
process.env.UNSPLASH_ACCESS_KEY = "test-key";
49+
mockFetchJson({
50+
results: [
51+
{
52+
urls: { regular: "https://images.unsplash.com/abc", small: "https://images.unsplash.com/abc-sm" },
53+
alt_description: "kitchen",
54+
user: { name: "Alice" },
55+
width: 1200,
56+
height: 800,
57+
},
58+
],
59+
});
60+
const out = await findStockImage("kitchen", { limit: 1 });
61+
expect(out.source).toBe("unsplash");
62+
expect(out.results[0].url).toBe("https://images.unsplash.com/abc");
63+
expect(out.results[0].attribution).toContain("Alice");
64+
expect(out.warning).toBeUndefined();
65+
});
66+
67+
it("respects explicit source override", async () => {
68+
const out = await findStockImage("kitchen", { source: "picsum", limit: 1 });
69+
expect(out.source).toBe("picsum");
70+
expect(out.warning).toBeUndefined();
71+
});
72+
73+
it("falls through when explicit source has no key", async () => {
74+
const out = await findStockImage("kitchen", { source: "unsplash", limit: 1 });
75+
expect(out.source).toBe("picsum");
76+
expect(out.warning).toMatch(/UNSPLASH_ACCESS_KEY/);
77+
});
78+
79+
it("sends Authorization header for unsplash", async () => {
80+
process.env.UNSPLASH_ACCESS_KEY = "k1";
81+
mockFetchJson({ results: [] });
82+
await findStockImage("a", { source: "unsplash" });
83+
const headers = global.fetch.mock.calls[0][1].headers;
84+
expect(headers.Authorization).toBe("Client-ID k1");
85+
});
86+
87+
it("sends Authorization header for pexels", async () => {
88+
process.env.PEXELS_API_KEY = "k2";
89+
mockFetchJson({ photos: [] });
90+
await findStockImage("a", { source: "pexels" });
91+
const headers = global.fetch.mock.calls[0][1].headers;
92+
expect(headers.Authorization).toBe("k2");
93+
});
94+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// @vitest-environment node
2+
3+
import { describe, it, expect } from "vitest";
4+
import { searchPhotos } from "../picsum.js";
5+
6+
describe("picsum.searchPhotos", () => {
7+
it("returns the requested number of results (default 5)", () => {
8+
const r = searchPhotos("kitchen");
9+
expect(r.results).toHaveLength(5);
10+
});
11+
12+
it("clamps limit between 1 and 20", () => {
13+
expect(searchPhotos("a", { limit: 9999 }).results).toHaveLength(20);
14+
expect(searchPhotos("a", { limit: 0 }).results).toHaveLength(1);
15+
});
16+
17+
it("produces deterministic URLs for the same query", () => {
18+
const a = searchPhotos("kitchen", { limit: 3 });
19+
const b = searchPhotos("kitchen", { limit: 3 });
20+
expect(a.results.map((r) => r.url)).toEqual(b.results.map((r) => r.url));
21+
});
22+
23+
it("differs across queries", () => {
24+
const a = searchPhotos("kitchen", { limit: 3 });
25+
const b = searchPhotos("forest", { limit: 3 });
26+
expect(a.results[0].url).not.toBe(b.results[0].url);
27+
});
28+
29+
it("uses picsum.photos host", () => {
30+
const r = searchPhotos("home");
31+
for (const item of r.results) {
32+
expect(item.url.startsWith("https://picsum.photos/")).toBe(true);
33+
expect(item.source).toBe("picsum");
34+
}
35+
});
36+
});

0 commit comments

Comments
 (0)