diff --git a/CHANGELOG.md b/CHANGELOG.md index ea14003..8792ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,34 @@ versions adhere to [Semantic Versioning](https://semver.org). ## [Unreleased] +### Added + +- **`GET /` now serves a small HTML landing page** with proper + `` and + `` 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 ``, 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 `` + 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 `` tags' shape, the `/mcp` + reference in the body, the cache-control header, and a + determinism check (two requests return identical bytes). + ### Fixed - **`serverInfo.icons` now emits a URL-form entry first when diff --git a/src/http/app.ts b/src/http/app.ts index 3e59111..8774f3e 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -200,6 +200,53 @@ export function createApp(opts: AppOptions): express.Express { app.get("/icon.svg", iconHandler); app.get("/favicon.ico", iconHandler); + // ── Landing page (cosmetic) ────────────────────────────────────────────── + // Browser-style favicon discovery walks the `` tags in the HTML head + // at `/`. Without a real HTML response, Express's default 404 page renders + // with an empty , and any client doing browser-shaped icon discovery + // (favicon checkers, possibly Claude.ai's connector UI) gives up there. + // + // This route serves a tiny static page that: + // - Declares the SVG icon via + apple-touch-icon + // - Tells human visitors what this URL is and where the MCP endpoint + // lives (so a curious dev hitting the URL in a browser doesn't see + // "Cannot GET /" and assume the service is down) + // + // Static bytes — no user input, no template interpolation, no XSS surface. + // Generic content only (no deployment-specific URLs or tenant info). + // 1h cache so an icon refresh propagates within a reasonable window. + const LANDING_HTML = ` + + + + +capsulemcp + + + + + + +

capsulemcp

+

This is the HTTP+OAuth deployment of capsulemcp, a Model Context Protocol (MCP) server for Capsule CRM.

+

The MCP endpoint is at /mcp. Use Claude.ai's Custom Connector flow (or any MCP-compatible client) to connect — this URL is not navigable by hand.

+

Source: github.com/soil-dev/capsulemcp · License: Apache-2.0

+ + +`; + app.get("/", (_req, res) => { + res + .set("Content-Type", "text/html; charset=utf-8") + .set("Cache-Control", "public, max-age=3600") + .send(LANDING_HTML); + }); + // ── MCP endpoint (gated by Bearer token from the OAuth provider) ───────── const guardOrigin: express.RequestHandler = (req, res, next) => { const origin = req.get("Origin"); diff --git a/tests/http-app.test.ts b/tests/http-app.test.ts index b8fda7a..c25214e 100644 --- a/tests/http-app.test.ts +++ b/tests/http-app.test.ts @@ -622,6 +622,66 @@ describe("Icon endpoints (cosmetic)", () => { }); }); +describe("Landing page (cosmetic)", () => { + // `/` serves a tiny HTML page so browser-style favicon discovery + // (which walks `` tags in the head) has something to find. + // Without it, Express's default 404 renders an empty and + // any client doing HTML-shaped icon discovery (favicon checkers, + // possibly some connector UIs) gives up. Locking the contract. + + it("GET / returns 200 with HTML content-type", async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + }); + + it("response body declares the SVG icon via ", async () => { + const res = await fetch(`${baseUrl}/`); + const body = await res.text(); + // type="image/svg+xml" is the explicit hint that lets browsers + // skip fetching the icon when they can't render SVG (rare today + // but the spec rewards being explicit). + expect(body).toMatch(/ { + // iOS home-screen pinning fetches apple-touch-icon by spec name. + // No semantic difference from the regular icon for SVG, but + // belt-and-suspenders for the favicon-checker green-row count. + const res = await fetch(`${baseUrl}/`); + const body = await res.text(); + expect(body).toMatch(/ { + // If a dev hits the URL in a browser by mistake, they should see + // *something* useful, not "Cannot GET /". The /mcp anchor is the + // most concrete next-step a human reader needs. + const res = await fetch(`${baseUrl}/`); + const body = await res.text(); + expect(body).toContain("/mcp"); + expect(body).toMatch(/capsulemcp/i); + }); + + it("landing page has a 1h Cache-Control (shorter than the icon's 24h)", async () => { + // Page text might evolve as we polish copy; 1h means a fix + // propagates the same day. Icon bytes change ~never, hence 24h. + const res = await fetch(`${baseUrl}/`); + const cache = res.headers.get("cache-control"); + expect(cache).toContain("public"); + expect(cache).toContain("max-age=3600"); + }); + + it("no template interpolation surfaces — body bytes match across requests", async () => { + // Defensive: catches an accidental refactor that introduces e.g. + // ${requestId} into the response. Two requests must produce + // identical bytes (modulo gzip variation, which fetch handles). + const a = await (await fetch(`${baseUrl}/`)).text(); + const b = await (await fetch(`${baseUrl}/`)).text(); + expect(a).toBe(b); + }); +}); + // ── Helpers ────────────────────────────────────────────────────────────────── async function getAuthCode(): Promise {