Skip to content

Commit 9574ee8

Browse files
committed
Fix MCP-generated cards showing the wrong logo for every non-nyuchi brand
generate_studio_card and generate_article_banner never registered any brand icons with the SVG engines — loadBrandIcons() is browser-only (fetches a site-relative path against the page's own origin), and nothing in mcp/src ever called it. Every MCP-generated card for every brand therefore fell back to the engines' generic placeholder mark (a synthetic node graph, drawn in the theme's default accent color — nyuchi's gold in dark mode), regardless of which brand was requested. Reported as "wrong logos for Mukoko"; confirmed the same is true for every brand except nyuchi, where the generic mark happens to roughly match by coincidence of color. Fix: give loadBrandIcons() an injectable fetch implementation (default unchanged — the browser call sites need no changes), and add mcp/src/brand-icons.ts, which loads the same vendored PNGs through the Worker's ASSETS binding instead of a page-relative fetch. Wired into generate_studio_card/generate_article_banner, cached per isolate since the icon set never changes at runtime. Verified against a real local `wrangler dev` (not just the test stub): generate_studio_card for brand=mukoko now embeds the actual vendored Mukoko mark. Added two worker tests asserting a real <image> tag appears when ASSETS is available, guarding against regressing to the placeholder. 247 tests green (173 + 74).
1 parent 8491a00 commit 9574ee8

4 files changed

Lines changed: 112 additions & 3 deletions

File tree

mcp/src/brand-icons.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Registers the real per-brand icons (the same vendored PNGs the SPA's
3+
* Studio/Banner pages use) with both SVG engines' icon stores, so
4+
* generate_studio_card/generate_article_banner draw the actual brand mark
5+
* instead of falling back to the engines' generic placeholder mark.
6+
*
7+
* loadBrandIcons() normally resolves its icon paths with the browser's own
8+
* fetch — there's no page origin to resolve a relative path against inside
9+
* a Worker, so this passes a fetch implementation backed by the `ASSETS`
10+
* binding instead (the vendored PNGs/`.b64.txt` files are static-passthrough
11+
* assets, bundled into the same `signature-generator/dist` the Worker
12+
* serves everything else from).
13+
*
14+
* Cached per isolate: the icon set never changes at runtime, so this only
15+
* does real work once per cold start.
16+
*/
17+
18+
import { loadBrandIcons } from "../../signature-generator/src/lib/loadBrandIcons";
19+
import { setBrandIcon as setStudioIcon } from "../../signature-generator/src/engines/nyuchi";
20+
import { setBrandIcon as setBannerIcon } from "../../signature-generator/src/engines/banner";
21+
22+
let cached: Promise<void> | null = null;
23+
24+
export function ensureBrandIconsLoaded(assets: Fetcher | undefined): Promise<void> {
25+
if (!assets) return Promise.resolve();
26+
if (!cached) {
27+
const assetsFetch: typeof fetch = (input) => {
28+
const path = typeof input === "string" ? input : input instanceof URL ? input.pathname : input.url;
29+
return assets.fetch(new Request(new URL(path, "https://assets.internal")));
30+
};
31+
cached = Promise.all([
32+
loadBrandIcons(setStudioIcon, assetsFetch),
33+
loadBrandIcons(setBannerIcon, assetsFetch),
34+
]).then(() => undefined);
35+
}
36+
return cached;
37+
}

mcp/src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
buildSVG as buildArticleBanner,
8080
type Params as BannerParams,
8181
} from "../../signature-generator/src/engines/banner";
82+
import { ensureBrandIconsLoaded } from "./brand-icons.js";
8283

8384
/** One-line brand taxonomy, appended to every `brand` param description. */
8485
const BRAND_TAXONOMY =
@@ -150,7 +151,7 @@ class WorkerHttpTransport implements Transport {
150151
// MCP server + tool registrations.
151152
// -----------------------------------------------------------------------------
152153

153-
function buildServer(): McpServer {
154+
function buildServer(env: Env): McpServer {
154155
const server = new McpServer({
155156
name: SERVER_NAME,
156157
version: SERVER_VERSION,
@@ -249,6 +250,7 @@ function buildServer(): McpServer {
249250
brand?: StudioParams["brand"];
250251
seedKey?: string;
251252
}) => {
253+
await ensureBrandIconsLoaded(env.ASSETS);
252254
const layout = args.layout ?? 5;
253255
const params: StudioParams = {
254256
format: args.format ?? "ig",
@@ -331,6 +333,7 @@ function buildServer(): McpServer {
331333
brand?: BannerParams["brand"];
332334
seedKey?: string;
333335
}) => {
336+
await ensureBrandIconsLoaded(env.ASSETS);
334337
const layout = args.layout ?? 1;
335338
const params: BannerParams = {
336339
format: args.format ?? "16x9",
@@ -631,7 +634,7 @@ app.post("/mcp", async (c) => {
631634
}
632635

633636
const messages = Array.isArray(body) ? body : [body];
634-
const server = buildServer();
637+
const server = buildServer(c.env);
635638
const transport = new WorkerHttpTransport();
636639
await server.connect(transport);
637640

mcp/tests/worker.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ const SITE_ENV = { SESSION_SECRET: TEST_SESSION_SECRET }
3838
* (`app.all("*", (c) => c.env.ASSETS.fetch(...))`) provide this instead. */
3939
const ASSETS_STUB = { fetch: async () => new Response('stub-asset') }
4040

41+
/** Stub ASSETS binding that answers brand-icon `.b64.txt` requests with a
42+
* fixed fake payload, so tests can verify generate_studio_card/
43+
* generate_article_banner actually embed a real per-brand icon (via
44+
* mcp/src/brand-icons.ts) instead of falling back to the engines' generic
45+
* placeholder mark — the exact bug this guards against regressing to. */
46+
const FAKE_ICON_B64 = 'ZmFrZS1pY29uLWJ5dGVz'
47+
const ICON_ASSETS_STUB = {
48+
fetch: async (req: Request) => {
49+
const url = new URL(req.url)
50+
if (url.pathname.endsWith('.b64.txt')) return new Response(FAKE_ICON_B64)
51+
return new Response('not found', { status: 404 })
52+
},
53+
}
54+
4155
type Env = Record<string, unknown>
4256

4357
function get(path: string, env: Env = OPEN_ENV, headers?: HeadersInit): Promise<Response> {
@@ -467,6 +481,53 @@ describe('POST /mcp — JSON-RPC', () => {
467481
expect(result.content[0].text).toContain('-32602')
468482
})
469483

484+
// These two run last in the describe block: loading brand icons populates
485+
// a module-level cache shared by every call in this test file (mirroring
486+
// one Worker isolate's lifetime), so once these run, every studio/banner
487+
// test after them would also see real icons instead of the wordmark-only
488+
// fallback the tests above assert around.
489+
it('generate_studio_card embeds the real brand icon when ASSETS is available', async () => {
490+
const res = await post(
491+
'/mcp',
492+
rpc(
493+
'tools/call',
494+
{
495+
name: 'generate_studio_card',
496+
arguments: { title: 'What is nhimbe?', category: 'malachite', brand: 'mukoko' },
497+
},
498+
18,
499+
),
500+
{ ...OPEN_ENV, ASSETS: ICON_ASSETS_STUB },
501+
)
502+
expect(res.status).toBe(200)
503+
const body = (await res.json()) as JsonRpcResponse
504+
expect(body.error).toBeUndefined()
505+
const svg = (body.result as { content: { text: string }[] }).content[0].text
506+
expect(svg).toContain(`<image href="data:image/png;base64,${FAKE_ICON_B64}"`)
507+
expect(svg).toContain('>mukoko.com</text>')
508+
})
509+
510+
it('generate_article_banner embeds the real brand icon when ASSETS is available', async () => {
511+
const res = await post(
512+
'/mcp',
513+
rpc(
514+
'tools/call',
515+
{
516+
name: 'generate_article_banner',
517+
arguments: { title: 'X', category: 'malachite', brand: 'mukoko' },
518+
},
519+
19,
520+
),
521+
{ ...OPEN_ENV, ASSETS: ICON_ASSETS_STUB },
522+
)
523+
expect(res.status).toBe(200)
524+
const body = (await res.json()) as JsonRpcResponse
525+
expect(body.error).toBeUndefined()
526+
const svg = (body.result as { content: { text: string }[] }).content[0].text
527+
expect(svg).toContain(`<image href="data:image/png;base64,${FAKE_ICON_B64}"`)
528+
expect(svg).toContain('>mukoko.com</text>')
529+
})
530+
470531
it('malformed JSON gets a -32700 parse error with HTTP 400', async () => {
471532
const res = await post('/mcp', '{not json')
472533
expect(res.status).toBe(400)

signature-generator/src/lib/loadBrandIcons.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,25 @@ import { TOP_BRANDS, type TopBrandKey } from '../engines/brands'
88
*
99
* Missing files resolve silently — the engines draw a wordmark-only lockup
1010
* (or their built-in mark) for brands without a registered icon.
11+
*
12+
* `fetchImpl` defaults to the global `fetch`, which resolves the site-
13+
* relative icon paths correctly in a browser. The `nyuchi-tools` Worker has
14+
* no browser origin to resolve a relative path against, so it passes a
15+
* fetch implementation backed by its `ASSETS` binding instead (see
16+
* `mcp/src/brand-icons.ts`) — same paths, same `.b64.txt` files, different
17+
* transport.
1118
*/
1219
export async function loadBrandIcons(
1320
setBrandIcon: (brand: TopBrandKey, dataUri: string, theme?: 'light' | 'dark') => void,
21+
fetchImpl: typeof fetch = fetch,
1422
): Promise<void> {
1523
const jobs: Promise<void>[] = []
1624
for (const brand of Object.values(TOP_BRANDS)) {
1725
for (const theme of ['light', 'dark'] as const) {
1826
const path = brand.icon[theme]
1927
if (!path || !path.endsWith('.png')) continue
2028
jobs.push(
21-
fetch(path.replace(/\.png$/, '.b64.txt'))
29+
fetchImpl(path.replace(/\.png$/, '.b64.txt'))
2230
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(`${path}: ${r.status}`))))
2331
.then((txt) => setBrandIcon(brand.key, 'data:image/png;base64,' + txt.trim(), theme))
2432
.catch(() => {

0 commit comments

Comments
 (0)