diff --git a/CHANGELOG.md b/CHANGELOG.md index fef0e0b..ea14003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,29 @@ versions adhere to [Semantic Versioning](https://semver.org). ### Fixed +- **`serverInfo.icons` now emits a URL-form entry first when + `PUBLIC_BASE_URL` is set (HTTP+OAuth deploys), with the existing + data-URI form retained as a second fallback entry.** Some client + UIs (observed: Claude.ai's connector list on the Cloud Run URL) + silently skip `data:` image srcs — plausibly a CSP / `img-src` + policy that disallows data URIs. A sibling MCP server fronted by + a custom domain rendered its icon correctly with the URL form + while capsulemcp on `*.run.app` didn't, even though the SVG bytes + were identical. The fix emits both shapes (URL first so clients + iterating the array pick it; data URI second for stdio + CSP- + tolerant clients). Stdio installs (no `PUBLIC_BASE_URL`) see no + behavioural change — still data-URI-only. + + Refactor: the icons array shape now lives in + `src/icon-builder.ts` (hand-edited) instead of inside the SVG + generator at `scripts/build-icon.mjs`. `src/icon.ts` is now pure + generated data (`ICON_SVG`, `ICON_DATA_URI`); the orchestration + (URL vs data URI ordering, sizes hints) is separate. The `ICONS` + named export from `src/icon.ts` is gone — replaced by + `buildIcons(publicBaseUrl?)` in `src/icon-builder.ts`. No external + consumer relied on `ICONS` (single internal call site in + `src/server.ts`). + - **Tool annotations now emit `{readOnlyHint, destructiveHint}` explicitly on every tool — never rely on MCP spec defaults.** Per spec, `destructiveHint` defaults to `true`. The pre-fix diff --git a/scripts/build-icon.mjs b/scripts/build-icon.mjs index af2d893..586b047 100644 --- a/scripts/build-icon.mjs +++ b/scripts/build-icon.mjs @@ -43,29 +43,23 @@ const generated = `/** * edit this file directly** — edit the SVG and re-run \`npm run * build:icon\` (or \`npm run build\`, which chains it). * - * Exposed two ways: + * This file is generated DATA: the raw SVG plus its \`data:\` URI + * form. The \`serverInfo.icons\` ARRAY shape (URL form vs data URI + * form vs both, ordering, sizes hints) is hand-edited orchestration + * and lives in \`src/icon-builder.ts\` — kept out of this generator + * so the icon-array shape can evolve without touching the SVG. + * + * Exposed two ways at runtime: * - Embedded as a \`data:\` URI in the MCP \`serverInfo.icons\` array - * (spec-compliant; works without any HTTP route). + * (spec-compliant; works without any HTTP route — stdio path). * - Served at \`/icon.svg\` and \`/favicon.ico\` by the HTTP entry, - * in case the consuming client prefers a URL it can fetch. + * so clients that prefer to fetch a URL get a real HTTPS resource + * (some UIs' CSP blocks \`data:\` image srcs — URL form survives). */ export const ICON_SVG = \`${escapedSvg}\`; export const ICON_DATA_URI = \`data:image/svg+xml;base64,\${Buffer.from(ICON_SVG, "utf8").toString("base64")}\`; - -/** - * Shaped for MCP's \`serverInfo.icons\` field. Single 64x64 SVG that - * scales cleanly to any size; \`sizes: ["any"]\` tells the client it - * works at every render size. - */ -export const ICONS = [ - { - src: ICON_DATA_URI, - mimeType: "image/svg+xml", - sizes: ["64x64", "any"], - }, -]; `; writeFileSync(TS_PATH, generated, "utf8"); diff --git a/src/icon-builder.ts b/src/icon-builder.ts new file mode 100644 index 0000000..2a36ad6 --- /dev/null +++ b/src/icon-builder.ts @@ -0,0 +1,65 @@ +/** + * Builds the `serverInfo.icons` array shape for MCP's `initialize` + * response. Lives separately from the generated `src/icon.ts` so the + * shape can evolve (URL form, multiple sizes, fallback ordering) + * without touching the SVG generator. + * + * Why two forms (URL + data URI): + * + * - URL form (`https:///icon.svg`) — preferred by client UIs + * whose Content-Security-Policy blocks `data:` image srcs in + * `` tags. Empirically: a sibling MCP server (Discourse- + * flavored, served from a custom domain) renders its icon + * correctly in Claude.ai's connector list while capsulemcp on + * a `*.run.app` URL did not — the difference traced to URL-vs- + * data-URI src shape, not the icon bytes themselves. + * - Data URI form — host-independent fallback. The stdio entry + * has no HTTP route to serve the SVG from, so the data URI is + * the only viable shape there. Also useful for clients that + * prefer inline bytes (no extra fetch round-trip). + * + * Ordering rule: URL first when available, then data URI. Clients + * that iterate the array and pick the first usable entry get the + * URL form by default and never see the data URI; clients that + * filter by `mimeType` or `sizes` see both and can pick either. + * + * When `publicBaseUrl` is undefined (stdio invocation, or HTTP + * deploy that hasn't configured `PUBLIC_BASE_URL`), only the data + * URI entry is emitted — preserves the pre-v1.6.x behaviour exactly. + */ + +import { ICON_DATA_URI } from "./icon.js"; + +interface IconEntry { + src: string; + mimeType: string; + sizes: string[]; +} + +export function buildIcons(publicBaseUrl?: string): IconEntry[] { + const icons: IconEntry[] = []; + + if (publicBaseUrl) { + // Strip trailing slash so the URL doesn't render as + // `https://host//icon.svg` if the caller passes a base ending in /. + // (Express's iconHandler at /icon.svg responds to either form, but + // the wire shape should look clean to humans reading the + // initialize response.) + const base = publicBaseUrl.replace(/\/+$/, ""); + icons.push({ + src: `${base}/icon.svg`, + mimeType: "image/svg+xml", + sizes: ["any"], + }); + } + + // Data URI: host-independent fallback. Always present — covers + // stdio (no HTTP route) and clients that prefer inline bytes. + icons.push({ + src: ICON_DATA_URI, + mimeType: "image/svg+xml", + sizes: ["64x64", "any"], + }); + + return icons; +} diff --git a/src/icon.ts b/src/icon.ts index 0804cdb..ad2780a 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -7,11 +7,18 @@ * edit this file directly** — edit the SVG and re-run `npm run * build:icon` (or `npm run build`, which chains it). * - * Exposed two ways: + * This file is generated DATA: the raw SVG plus its `data:` URI + * form. The `serverInfo.icons` ARRAY shape (URL form vs data URI + * form vs both, ordering, sizes hints) is hand-edited orchestration + * and lives in `src/icon-builder.ts` — kept out of this generator + * so the icon-array shape can evolve without touching the SVG. + * + * Exposed two ways at runtime: * - Embedded as a `data:` URI in the MCP `serverInfo.icons` array - * (spec-compliant; works without any HTTP route). + * (spec-compliant; works without any HTTP route — stdio path). * - Served at `/icon.svg` and `/favicon.ico` by the HTTP entry, - * in case the consuming client prefers a URL it can fetch. + * so clients that prefer to fetch a URL get a real HTTPS resource + * (some UIs' CSP blocks `data:` image srcs — URL form survives). */ export const ICON_SVG = ` @@ -36,16 +43,3 @@ export const ICON_SVG = ` { + it("stdio shape: undefined publicBaseUrl → data-URI-only entry", () => { + const icons = buildIcons(undefined); + + expect(icons).toHaveLength(1); + expect(icons[0]?.src.startsWith("data:image/svg+xml;base64,")).toBe(true); + expect(icons[0]?.mimeType).toBe("image/svg+xml"); + // Preserves the historic `sizes: ["64x64", "any"]` for stdio. + expect(icons[0]?.sizes).toEqual(["64x64", "any"]); + }); + + it("HTTP shape: URL-first, data-URI-second when publicBaseUrl is set", () => { + const icons = buildIcons("https://example.test"); + + expect(icons).toHaveLength(2); + + // URL entry comes first — clients picking the first array entry + // get the URL form (the fix's whole point). + expect(icons[0]?.src).toBe("https://example.test/icon.svg"); + expect(icons[0]?.mimeType).toBe("image/svg+xml"); + + // Data URI is the fallback for clients that can't reach the URL + // (CSP-restricted iframes, offline caches, etc.). + expect(icons[1]?.src.startsWith("data:image/svg+xml;base64,")).toBe(true); + expect(icons[1]?.mimeType).toBe("image/svg+xml"); + }); + + it("strips trailing slash on publicBaseUrl (no `host//icon.svg`)", () => { + const icons = buildIcons("https://example.test/"); + expect(icons[0]?.src).toBe("https://example.test/icon.svg"); + + // Multiple trailing slashes too — defensive against operator typos. + const icons2 = buildIcons("https://example.test///"); + expect(icons2[0]?.src).toBe("https://example.test/icon.svg"); + }); + + it("empty-string publicBaseUrl is treated as 'not set' (data-URI-only)", () => { + // Defensive: an unset env var sometimes shows up as "" instead + // of undefined depending on how it's read. Both should fall back + // to the stdio shape rather than producing a relative URL like + // `/icon.svg` that's not resolvable. + const icons = buildIcons(""); + + expect(icons).toHaveLength(1); + expect(icons[0]?.src.startsWith("data:")).toBe(true); + }); +});