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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ versions adhere to [Semantic Versioning](https://semver.org).

## [Unreleased]

### Added

- **`GET /` now serves a small HTML landing page** with proper
`<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).

### Fixed

- **`serverInfo.icons` now emits a URL-form entry first when
Expand Down
47 changes: 47 additions & 0 deletions src/http/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<link>` tags in the HTML head
// at `/`. Without a real HTML response, Express's default 404 page renders
// with an empty <head>, 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 <link rel="icon"> + 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 = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>capsulemcp</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<link rel="apple-touch-icon" href="/icon.svg">
<meta name="description" content="Model Context Protocol server for Capsule CRM. MCP endpoint: /mcp">
<style>
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:42em;margin:3em auto;padding:0 1em;color:#222;line-height:1.5}
h1{font-size:1.6em;margin-bottom:0.2em}
code{background:#f3f3f3;padding:0.1em 0.35em;border-radius:3px;font-size:0.95em}
a{color:#1e3a8a}
.muted{color:#666;font-size:0.92em}
</style>
</head>
<body>
<h1>capsulemcp</h1>
<p>This is the HTTP+OAuth deployment of <a href="https://github.com/soil-dev/capsulemcp">capsulemcp</a>, a Model Context Protocol (MCP) server for Capsule CRM.</p>
<p>The MCP endpoint is at <code>/mcp</code>. Use Claude.ai's Custom Connector flow (or any MCP-compatible client) to connect &mdash; this URL is not navigable by hand.</p>
<p class="muted">Source: <a href="https://github.com/soil-dev/capsulemcp">github.com/soil-dev/capsulemcp</a> &middot; License: Apache-2.0</p>
</body>
</html>
`;
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");
Expand Down
60 changes: 60 additions & 0 deletions tests/http-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,66 @@ describe("Icon endpoints (cosmetic)", () => {
});
});

describe("Landing page (cosmetic)", () => {
// `/` serves a tiny HTML page so browser-style favicon discovery
// (which walks `<link>` tags in the head) has something to find.
// Without it, Express's default 404 renders an empty <head> 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 <link rel='icon'>", 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(/<link\s+rel="icon"\s+type="image\/svg\+xml"\s+href="\/icon\.svg"/);
});

it("response body declares the apple-touch-icon", async () => {
// 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(/<link\s+rel="apple-touch-icon"\s+href="\/icon\.svg"/);
});

it("response body is human-readable (mentions the MCP endpoint)", async () => {
// 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<string> {
Expand Down