Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions mcp-server/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
// Drawd MCP server entry point.
//
// CLI args:
// --file <path.drawd> 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";
Expand Down
85 changes: 85 additions & 0 deletions mcp-server/src/asset-fetchers/__tests__/cache.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
95 changes: 95 additions & 0 deletions mcp-server/src/asset-fetchers/__tests__/iconify.test.js
Original file line number Diff line number Diff line change
@@ -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("<svg xmlns=\"http://www.w3.org/2000/svg\"><path/></svg>");
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("<svg></svg>");
await expect(fetchIcon("mdi", "missing")).rejects.toThrow(/Icon not found/);
});

it("returns the SVG body verbatim on success", async () => {
const body = "<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"4\"/></svg>";
mockFetchOnce(body);
const out = await fetchIcon("mdi", "home");
expect(out).toBe(body);
});

it("uses cache on second lookup", async () => {
const body = "<svg xmlns=\"http://www.w3.org/2000/svg\"><path/></svg>";
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");
});
});
94 changes: 94 additions & 0 deletions mcp-server/src/asset-fetchers/__tests__/orchestrator.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
36 changes: 36 additions & 0 deletions mcp-server/src/asset-fetchers/__tests__/picsum.test.js
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
Loading
Loading