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
65 changes: 19 additions & 46 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,57 +13,30 @@ versions adhere to [Semantic Versioning](https://semver.org).

### Added

- **`GET /` now serves a small HTML landing page** with proper
- **`GET /` now serves a small HTML landing page** — a short
human-readable body pointing at `/mcp` and the GitHub repo, plus
`<link rel="icon" type="image/svg+xml" href="/icon.svg">` and
`<link rel="apple-touch-icon" href="/icon.svg">` tags in the
head, plus a short human-readable body pointing at `/mcp` and
the GitHub repo. Closes the browser-style favicon-discovery gap:
before this change, hitting `/` returned Express's default 404
HTML with an empty `<head>`, so any client doing HTML-shaped
icon discovery (favicon checkers, some connector UIs) found no
hints and fell back to nothing.

Pairs with the v1.6.6 URL-form `serverInfo.icons` fix so we now
cover BOTH icon-discovery channels:
- MCP protocol channel: `serverInfo.icons` returns a URL entry
first (data URI fallback second) — for clients that read the
MCP initialize response.
- HTML channel: `GET /` returns an HTML page with `<link rel>`
tags pointing at `/icon.svg` — for clients that crawl the root
URL like a browser.

Static bytes, no template interpolation, no XSS surface. 1h
cache. Generic content only (no deployment-specific URLs or
tenant info in the body). 6 new tests in `tests/http-app.test.ts`
cover: content-type, the two `<link>` tags' shape, the `/mcp`
reference in the body, the cache-control header, and a
determinism check (two requests return identical bytes).
head. Before this, hitting the root URL in a browser returned
Express's default "Cannot GET /" — now a curious visitor (or a
favicon checker that parses HTML) gets something useful.

Scope note: this does NOT change how Claude.ai's connector UI
displays the server icon. We empirically traced that mechanism
to a favicon crawler (AWS-hosted, hourly, browser user-agents)
that fetches `/favicon.ico` directly — it does not parse the
landing-page `<link>` tags, and it only crawls **custom domains**,
not Public-Suffix-List platform subdomains like `*.run.app`. The
load-bearing route for that crawler (`/favicon.ico`) predates
this change; getting Claude.ai to show the icon requires mapping
the service to a custom domain, which is infra-side, not a code
change here. The landing page stands on its own DX merit. 6 new
tests in `tests/http-app.test.ts` cover content-type, the two
`<link>` tags, the `/mcp` reference, cache-control, and a
determinism check.

### 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
4 changes: 2 additions & 2 deletions HOWTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ npm install
npm test
```

523 tests, all mocked — no Capsule API calls happen, no token needed. The suite has three layers:
529 tests, all mocked — no Capsule API calls happen, no token needed. The suite has three layers:

- **Per-tool unit tests** (e.g. `tests/parties.test.ts`): import the tool function, mock `undici.fetch`, assert on the URL, method, body, and response handling. Most tests live here.
- **MCP-protocol integration tests** (`tests/mcp-integration.test.ts`): drive a real `McpServer` through the wire protocol via the SDK's in-memory transport pair, with `undici.fetch` still mocked. Catches the layer between "tool function works" and "MCP correctly registers and dispatches the tool". Includes the `get_attachment` content-type routing logic (which lives in `server.ts`, not the tool function).
Expand Down Expand Up @@ -46,7 +46,7 @@ for the contributor-facing summary.
npm run build
```

Produces `dist/index.js` (stdio entry, ~165 KB, with `#!/usr/bin/env node` shebang and the executable bit set) and `dist/http.js` (HTTP entry, ~191 KB, no shebang). Each is fully self-contained — tsup runs as two separate configs so the stdio entry can be invoked directly via npx while the HTTP entry isn't a CLI. tsup target is Node 22 (undici 8 requires Node 22+ for the `webidl.util.markAsUncloneable` runtime API).
Produces `dist/index.js` (stdio entry, ~165 KB, with `#!/usr/bin/env node` shebang and the executable bit set) and `dist/http.js` (HTTP entry, ~193 KB, no shebang). Each is fully self-contained — tsup runs as two separate configs so the stdio entry can be invoked directly via npx while the HTTP entry isn't a CLI. tsup target is Node 22 (undici 8 requires Node 22+ for the `webidl.util.markAsUncloneable` runtime API).

`npm run build` also chains `npm run build:icon` (`scripts/build-icon.mjs`), which regenerates `src/icon.ts` from the canonical `assets/icon.svg`. The TypeScript file is committed (so typecheck works without a build step) but is **generated** — edit the SVG, then run the build. A drift-guard test (`tests/icon-source.test.ts`) fails CI if the two ever fall out of sync.

Expand Down
26 changes: 16 additions & 10 deletions scripts/build-icon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,29 @@ const generated = `/**
* edit this file directly** — edit the SVG and re-run \`npm run
* build:icon\` (or \`npm run build\`, which chains it).
*
* 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:
* Exposed two ways:
* - Embedded as a \`data:\` URI in the MCP \`serverInfo.icons\` array
* (spec-compliant; works without any HTTP route — stdio path).
* (spec-compliant; works without any HTTP route).
* - Served at \`/icon.svg\` and \`/favicon.ico\` by the HTTP entry,
* so clients that prefer to fetch a URL get a real HTTPS resource
* (some UIs' CSP blocks \`data:\` image srcs — URL form survives).
* in case the consuming client prefers a URL it can fetch.
*/

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: 0 additions & 65 deletions src/icon-builder.ts

This file was deleted.

26 changes: 16 additions & 10 deletions src/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,11 @@
* edit this file directly** — edit the SVG and re-run `npm run
* build:icon` (or `npm run build`, which chains it).
*
* 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:
* Exposed two ways:
* - Embedded as a `data:` URI in the MCP `serverInfo.icons` array
* (spec-compliant; works without any HTTP route — stdio path).
* (spec-compliant; works without any HTTP route).
* - Served at `/icon.svg` and `/favicon.ico` by the HTTP entry,
* so clients that prefer to fetch a URL get a real HTTPS resource
* (some UIs' CSP blocks `data:` image srcs — URL form survives).
* in case the consuming client prefers a URL it can fetch.
*/

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 @@ -43,3 +36,16 @@ 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: 2 additions & 6 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 { buildIcons } from "./icon-builder.js";
import { ICONS } from "./icon.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,11 +254,7 @@ 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",
// 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"]),
icons: ICONS,
},
tasksWired
? {
Expand Down
2 changes: 1 addition & 1 deletion tests/bundle-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe.skipIf(!distExists)("bundle shape (post-build canary)", () => {
const httpKb = statSync(HTTP_PATH).size / 1024;
// Floor catches "the bundler produced an empty file"; ceiling
// catches "we accidentally inlined a giant dependency". The
// current values (~165 / ~191 KB) sit comfortably in the band.
// current values (~165 / ~193 KB) sit comfortably in the band.
expect(stdioKb).toBeGreaterThan(MIN_KB);
expect(stdioKb).toBeLessThan(MAX_KB);
expect(httpKb).toBeGreaterThan(MIN_KB);
Expand Down
65 changes: 0 additions & 65 deletions tests/icon-builder.test.ts

This file was deleted.