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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 10 additions & 16 deletions scripts/build-icon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
65 changes: 65 additions & 0 deletions src/icon-builder.ts
Original file line number Diff line number Diff line change
@@ -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://<base>/icon.svg`) — preferred by client UIs
* whose Content-Security-Policy blocks `data:` image srcs in
* `<img>` 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;
}
26 changes: 10 additions & 16 deletions src/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="capsulemcp">
Expand All @@ -36,16 +43,3 @@ export const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64
</svg>`;

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"],
},
];
8 changes: 6 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { isReadOnly } from "./capsule/client.js";
import { ICONS } from "./icon.js";
import { buildIcons } from "./icon-builder.js";
import { registerTool, registerToolTask } from "./server/register-tool.js";
import { getTasksConfig } from "./tasks/config.js";
import { createScopedTaskStore } from "./tasks/store.js";
Expand Down Expand Up @@ -254,7 +254,11 @@ export function createCapsuleMcpServer(opts?: { clientId?: string }): McpServer
description:
"Read and (optionally) modify Capsule CRM data — parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
websiteUrl: "https://github.com/soil-dev/capsulemcp",
icons: ICONS,
// PUBLIC_BASE_URL (HTTP+OAuth deploy) → emit URL-form icon
// first, then data: URI fallback. Stdio (no PUBLIC_BASE_URL)
// gets data-URI-only — preserves the pre-fix shape exactly.
// See src/icon-builder.ts for the URL-vs-data-URI rationale.
icons: buildIcons(process.env["PUBLIC_BASE_URL"]),
},
tasksWired
? {
Expand Down
65 changes: 65 additions & 0 deletions tests/icon-builder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Tests for `buildIcons()` — the `serverInfo.icons` array
* constructor.
*
* Pins three contracts:
* 1. Stdio shape (no publicBaseUrl) is data-URI-only — preserves
* the pre-fix wire shape so stdio installs see no behavioural
* change.
* 2. HTTP shape (publicBaseUrl present) is URL-first, data-URI
* second — clients iterating the array and picking the first
* usable entry get the URL form (the goal of the fix).
* 3. Trailing slash on publicBaseUrl is tolerated — `${base}/icon.svg`
* doesn't render as `host//icon.svg`.
*/

import { describe, expect, it } from "vitest";
import { buildIcons } from "../src/icon-builder.js";

describe("buildIcons", () => {
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);
});
});