15. The HTTP server and serve
- Status: accepted
- Date: 2026-09-30
Context
Build step 9 adds the Hono app (REST routes, /mcp, local auth, static UI hosting) and titlesearch serve. The brief asks for a decision between @hono/mcp and the SDK's own Streamable HTTP transport.
Decisions
MCP transport: the SDK's web-standard transport
@modelcontextprotocol/sdk 1.31 ships WebStandardStreamableHTTPServerTransport, which works on Web Request and Response objects and so on Bun, Node, and Workers alike. Its own docs show it mounted in Hono.
/mcp creates a fresh server and transport per request, with no sessionIdGenerator (stateless: no session IDs, no server-held state) and JSON responses. @hono/mcp 0.3.2 mostly adds OAuth helpers and a rate-limiter peer dependency. For deployed OAuth, the brief names workers-oauth-provider and external-IdP token validation instead (step 11), so @hono/mcp would add a dependency without adding anything the brief needs.
One runtime-agnostic app
packages/server uses only Hono and Web APIs, and its whole test suite runs unchanged in workerd (no Node compatibility flag) as well as on Node and Bun. The preview-image handler moved to @titlesearch/render/preview-response, so the server never imports the Node-only renderer.
Middleware, in order
- Security headers:
nosniff,no-referrer,X-Frame-Options: DENY, COOP, andno-storeon API responses. - The
Hostcheck: only127.0.0.1:<port>orlocalhost:<port>, which blocks DNS rebinding. If a request has noHostheader, the URL's host is used; a Request built in-process can't set one. - The
Origincheck: absent is allowed (curl, MCP clients); present must be ours;nullnever is. - A 64 KB body limit on the API.
- Auth on
/api/*(except the session endpoint) and/mcp: the bearer token, compared in constant time, or a session cookie. A cookie-authenticated change must also carry ourOrigin. - Per-principal rate limit: 120 requests a minute, returning 429 with
Retry-After. - Global concurrency ceiling on routes that reach the network: 8 at a time, with 32 queued, then 503.
Getting the browser signed in without a secret in a URL
Invariant 6 rules out a token in the URL, as in ?token=…. Instead, serve prints a one-time login code: 10 characters of Crockford base32, about 50 bits. It works once, expires in 5 minutes, and dies after 5 wrong tries; pressing Enter prints a new one. The UI exchanges it at POST /api/session for an HttpOnly, SameSite=Strict session cookie lasting 12 hours. API and MCP clients use the bearer token instead.
The token
The token is 32 random bytes, stored as hex in <config dir>/local-token with mode 0600. It's created on first run, and never printed except when it's replaced: POST /api/token/rotate returns the new token once so the user can update their tools. Replacing it doesn't sign out browser sessions.
Routes
The API mirrors the MCP tools and uses the same Zod schemas and core functions:
POST /api/checkGET /api/domain/:domainPOST /api/assessPOST /api/variantsGET /api/providers/health
In a browser, "client" assessment mode means evidence only, since there's no client model.
Also:
GET /api/providers/healthhas each provider checkexample.comthrough its normal, read-onlycheckmethod, cached for 5 minutes.GET /api/preview/:hashserves stored WebP previews.GETandPATCH /api/settingsback the Providers screen. A patch may change only non-secret settings (GoDaddy on or off, assessment mode, preview mode), and the schema is strict, so a key in a patch is rejected. Keys come from the environment or, on desktop, the keychain. Choosing "anthropic" without a key is refused.
The UI shell
The UI shell is served without auth, since it holds no data. HTML gets a strict CSP: same-origin only, no frames, no third-party anything. Hashed assets are cached as immutable. A compiled binary embeds apps/web/dist through a generated module (scripts/embed-ui.mjs, using Bun file imports). Without a build, serve shows a placeholder page.
Verified
A compiled binary on 2026-09-30, checked with curl:
- It listened on
127.0.0.1only, and the token file was-rw-------. - A spoofed
Hostgot 403, and no credentials got 401. - The bearer token ran a live check.
- The printed code signed in and yielded a working cookie.
- MCP initialized over HTTP.