# Hosted MCP: three trust surfaces over one domain

The local stdio Pattern MCP (see [pattern-mcp.md](./pattern-mcp.md)) runs as an ephemeral container a coding agent spawns itself. This doc covers the **hosted** counterpart: the same pattern registry (on two endpoints with different telemetry policies), plus a separate demo-state domain, all served over HTTP by the running v10r app — each behind its own trust boundary.

For running/testing the *local* server or adding a pattern record, see [mcp/README.md](../../../mcp/README.md). That doc stays local-server-only; this one is HTTP-only.

## Trust surfaces

Four surfaces sit over the same domain, with explicit, non-overlapping boundaries:

```
public  endpoint (/api/mcp/public)  -> public read-only tool registry       -> curated pattern data
private endpoint (/api/mcp/private) -> bearer auth -> SAME pattern registry -> curated pattern data + full self-telemetry
admin   endpoint (/api/mcp/admin)   -> bearer auth -> private tool registry -> demo-state service
admin   page     (/admin/mcp)       -> Better Auth requireAdmin             -> demo-state service
```

All three endpoints share one transport (`src/lib/server/mcp/transport.ts`) but are handed their tool registries explicitly — public and private the same read-only `publicPatternRegistry`, admin its own demo-state registry. The transport dispatches only tool names present in the registry it's given — a demo-state tool name POSTed to the public or private endpoint is rejected as an `isError` result, never dispatched. Hiding a tool from `tools/list` alone would not be enough; this is a hard dispatch allowlist, enforced in one place for all endpoints.

## A. Hosted public MCP — `POST /api/mcp/public`

No authentication — this endpoint is read-only by construction, not by convention: it's handed `publicPatternRegistry`, which contains only the six Pattern tools, so there is no mutation tool it could dispatch even if asked.

- **Tools:** the same six as the local stdio server — `search_patterns`, `get_pattern`, `get_file_excerpt`, `trace_capability`, `recommend_emulation_plan`, `validate_snippet`. All six work in the deployed Vercel artifact.
- **`get_file_excerpt` is served from a build-time snapshot, not the filesystem.** The registry-referenced public files are captured into `mcp/public-excerpts.snapshot.json` (regenerated by `scripts/mcp/build-public-excerpts.ts`) and imported statically, so Vite inlines them into the serverless function — no `fs`, no `process.cwd()`, no path-traversal surface at runtime. It only serves a path present on the registry-derived allowlist (`src/lib/server/mcp/patterns/allowlist.ts`), re-filtered through a secret denylist; arbitrary paths, `../` traversal, absolute paths, and secret files are rejected. Bounded to 250 lines / 36KB. The snapshot is drift-guarded by `mcp:excerpts:check` (a moved/renamed/edited reference fails the gate until the snapshot is regenerated).
- **Rate limit:** 60 requests/min per IP, via the project's native Upstash limiter (`$lib/server/api/rate-limit`).
- **`GET /api/mcp/public` → `405 Method Not Allowed`** (`Allow: POST`). Under Streamable HTTP, GET is the optional server→client SSE channel; this server does not implement SSE, so GET is refused. There is no discovery-JSON endpoint (it isn't part of MCP and isn't needed).
- **Data source:** the same `mcp/patterns.registry.json`, imported at build time as static JSON — Vercel-safe, no runtime `fs` access. The pure tool logic is re-implemented in `src/lib/server/mcp/patterns/` because the container-only stdio files under `mcp/` can't be imported into `src/` (their Bun `import.meta.dir` usage breaks `svelte-check`). A drift test, `src/lib/server/mcp/patterns/parity.test.ts`, guards the two copies against divergence.
- **Transport:** MCP Streamable HTTP, non-streaming — one JSON-RPC message per POST maps to one JSON-RPC response. The envelope is validated before dispatch (`jsonrpc` must be `"2.0"`; `id` must be a string, number, or null); a notification (no `id`) returns HTTP 202 with no body; malformed JSON returns an HTTP 400 parse error. `Origin` is validated (DNS-rebinding protection, see Security posture) and a present-but-unsupported `MCP-Protocol-Version` header returns HTTP 400.

## B. Hosted private admin MCP — `POST /api/mcp/admin`

Requires `Authorization: Bearer <MCP_ADMIN_TOKEN>`.

- Missing or invalid credentials → HTTP 401, with a `WWW-Authenticate: Bearer` header. Server has no token configured → HTTP 503. There is no fallback to public behavior in either case. Token comparison is constant-time over fixed-length SHA-256 digests, so there is no length branch to leak (`src/lib/server/mcp/auth.ts`).
- `GET /api/mcp/admin` → `405 Method Not Allowed`, unauthenticated, with no tool metadata. GET performs no auth, so the only bearer-checked method is the rate-limited POST — there is no separate credential-guessing path.
- **Tools:** exactly five, narrowly allowlisted — `get_mcp_page_state`, `set_mcp_page_message`, `set_mcp_page_color`, `reset_mcp_page_state`, `get_mcp_page_history`. No generic SQL, filesystem, shell, fetch, or arbitrary-key tool exists here. `get_mcp_page_history` returns both the before and after snapshot of each recorded change.
- Every write is validated and versioned, and is audited on a **best-effort** basis (an audit-write failure is logged but does not roll back the accepted change — do not treat audit persistence as guaranteed). Successful writes return before/after values, attributed to a machine identity `admin-mcp`.
- **Rate limit:** 120 requests/min per IP (applied before the bearer check).

## B2. Hosted private pattern MCP — `POST /api/mcp/private`

Requires `Authorization: Bearer <MCP_PRIVATE_TOKEN>` — a **separate realm** from the admin token: neither credential opens the other surface (`verifyPrivateMcpBearer` vs `verifyAdminMcpBearer`, both thin wrappers over one constant-time verifier).

Same six pattern tools, same registry object, same instructions as the public endpoint — the *only* differences are the bearer gate and the telemetry policy it unlocks. The surface exists so the operator's own private projects (e.g. densho) can consume the pattern registry over HTTPS while their usage is recorded in full in `mcp.call_log` with `surface = 'private'`:

- **`query_text` on every outcome**, not just the no-match path — on this lane the (question, answer) pair is the analysis artefact.
- **`response_text`** — the tool's answer, scrubbed for secret shapes, whitespace *not* collapsed (it is markdown), capped at 4000 chars by a database CHECK, and structurally impossible on any other surface (`mcp_call_response_scope`).
- **`workspace`** — a self-declared project label from the `X-V10r-Workspace` header, validated to `[a-z0-9][a-z0-9-]{0,31}` and dropped (never coerced) when invalid. A label, not an identity.
- **`traffic` is never `'external'`** (`mcp_call_private_not_external`, mirroring the admin CHECK) — so every external KPI excludes the lane with the same one filter it already has. Note curl against this surface still classifies `test` and preview deployments `preview`; lane queries filter on `surface`, never `traffic`.
- **Retention pass 1 (30d text-nulling) skips private rows** — they are minimised by row at 90 days instead. No third-party data can enter the lane: the questions are the operator's own, the answers are v10r's own registry text.

Rate limit: 120 requests/min per IP, applied **before** the bearer check. GET → unauthenticated 405 with no tool metadata. Full build info (commit SHA + env) in the identity, like admin — the surface is authenticated. The results are read on `/admin/mcp/usage` (the "Private lane" cards: recent calls with answer previews, an **unthresholded** gap list — one caller, so the ≥3-distinct-callers rule would suppress everything and protects nobody — tool mix, and a ≤30s repeat-ask probe).

Consumer registration (Claude Code, from another project):

```bash
claude mcp add --transport http --scope local v10r-patterns \
  https://www.v10r.dev/api/mcp/private \
  --header "Authorization: Bearer $V10R_MCP_PRIVATE_TOKEN" \
  --header "X-V10r-Workspace: <project-label>"
```

`--scope local` keeps the token in `~/.claude.json`, out of any committed `.mcp.json`.

## C. Persistent demo state

A singleton row, `mcp.demo_state` (Postgres schema `mcp`; `src/lib/server/db/schema/mcp/demo-state.ts`): columns `message`, `color`, `version`, `updatedAt`, `updatedBy`. Initial values: `message = "Hello, Velociraptor."`, `color = "blue"`.

- **Validation** (`src/lib/server/mcp/demo/validation.ts`): `message` is plain text, 1–500 chars, no control characters, never interpreted as HTML — stored raw, rendered escaped by Svelte. `color` must be one of a server-side allowlist: `blue, red, green, yellow, orange, purple`. Invalid input is rejected.
- **Every accepted mutation** uses optimistic compare-and-swap with bounded retry: read the current row, then `UPDATE … SET version = read.version + 1 … WHERE id = singleton AND version = read.version RETURNING …`. If a concurrent writer committed between the read and the write, the version-guarded UPDATE matches zero rows and the attempt retries with a fresh read — so the `before` in every response is the verified **immediate predecessor** of its `after`, never a stale snapshot. Each attempt is one guarded statement (no WebSocket transaction), so it works under the project's fetch-routed Neon driver. After a successful swap it records an admin audit-log row (best-effort; reuses `$lib/server/admin/audit`, no new audit system). If the retry budget is exhausted the mutation returns a bounded `conflict` error rather than a wrong `before`.
- **Domain service:** `src/lib/server/mcp/demo/service.ts` is framework-free and is the same boundary called by both the admin MCP tools and the admin page. No business logic lives in either route handler.

## D. Protected admin page — `/admin/mcp`

Localized route at `src/routes/[[locale=locale]]/admin/mcp/`, protected by the existing `requireAdmin` guard — inherited from the admin layout and re-asserted in the page load. Non-admins get a 404.

- **Read-only display** (MVP — no edit controls): message, a colored swatch, the semantic color *name* as text (color is never conveyed by color alone), version, last-updated timestamp, updater identity.
- A **"MCP Test"** nav item was added under the System group in the admin sidebar.
- If `mcp.demo_state` isn't provisioned yet, the page degrades to an "unavailable" notice instead of erroring.

The section now carries two tabs (`Demo` at `/admin/mcp`, `Usage` at `/admin/mcp/usage`) via `+layout.svelte`. The Demo tab's active check is an **exact** match rather than the usual `startsWith`, because `/admin/mcp` is a prefix of `/admin/mcp/usage` and would otherwise light up both tabs and emit two `aria-current="page"`. The demo view deliberately stays at the section root rather than redirecting to a child: `/admin/mcp` is named in four strings that ship to clients on the admin MCP wire (three tool descriptions plus `ADMIN_MCP_INSTRUCTIONS`).

## E. Usage telemetry — `mcp.call_log`

Every request reaching an `/api/mcp/*` route produces **exactly one row**, written at a single terminating edge after the outcome is decided. The question it exists to answer is not "how much traffic" but *"in what specific way is this MCP failing its consumers?"* — so the headline output is the set of queries that matched nothing, each naming a capability a consumer wanted and the pattern registry does not have.

**Seam.** `respondToMcpPost` takes a required `McpCallObserver` port (`src/lib/server/mcp/types.ts`). `observe()` returns `void`, so there is nothing to await and telemetry cannot add latency to, or fail, a response. A registry-level decorator was rejected: a seam *below* the method cannot *see* the method, which would lose `initialize`, `tools/list`, unknown-method probes and unknown-tool rejections. `http.ts` is the only frame holding both the request and response envelopes.

**Import boundary.** `http.ts`, `transport.ts` and `types.ts` must never acquire an edge to `$lib/server/db`, `$lib/server/api/*`, Redis or `@vercel/functions` — `http.test.ts` and `sdk-interop.test.ts` deliberately have zero mocks and drive the real path, and `$lib/server/db` constructs a Neon pool at module load. Enforced by `http.boundary.gate.test.ts`, not by convention. `telemetry/writer.ts` is the only module in the tree that imports the database.

**Why tool failures are classifiable.** `errorResult()` takes a **required** `ToolDiag` (a closed union). The transport builds two `isError` results itself — unknown-tool and tool-threw — and those deliberately carry *no* diag. That absence is the discriminator: `isError && diag === undefined` ⟹ the transport produced it. The inference is total *only* because the parameter is required. A handler that ever returns `isError` without one lands in `outcome = 'tool_error'`, which is not an outcome but a **coverage meter**: non-zero means an uninstrumented `errorResult` call site exists. `diag` is stripped by `toWire()` before serialization and is pinned absent from the wire by three tests, the strongest of which runs through the real MCP SDK client.

**What is deliberately absent.**

| Not recorded | Why |
|---|---|
| Raw IP, or any unkeyed derivative | `client_key` is `HMAC-SHA256(MCP_TELEMETRY_SALT, ip:ua:UTC-date)`. A bare digest over the IPv4 space plus real UA strings inverts on a laptop — that is obfuscation, not pseudonymisation. NULL on gate rows and NULL when the salt is unset (fail open on the *column*, never the row). |
| `Mcp-Session-Id` | Never minted or read. The dominant client does not echo it, caches a stale id across restarts, and the next revision removes the mechanism. Minting one would make every deploy a hard break for connected clients. Pinned by a gate test. |
| `baggage` / `tracestate` | Designed to carry arbitrary application key/value pairs (`userId=alice` is the canonical example) — recording them would import a third party's identifiers. Only the 32-hex `traceparent` trace-id is kept. |
| A funnel key | `trace_id` is per-agent-*turn*, not per-consumer. Its index deliberately omits `started_at` so it can serve a point lookup and **cannot** serve a time-bucketed `GROUP BY` — refusing to build the index that makes the wrong query fast. |
| `outcome = 'timeout'` | **Structurally unrecordable.** `waitUntil` promises are cancelled when the function times out, so the rows lost are exactly the rows describing timed-out requests. Do not read a clean latency histogram as proof there are none — that answer is in the platform logs. |

**Counting rules.** Arrivals are `SUM(observed_count)`, never `count(*)` (sampled rows carry their multiplier). Every KPI filters `traffic = 'external'`: preview deployments share the production database, and the operator's own tooling hits the same endpoint, so an unfiltered count reports dogfooding as adoption. `count(DISTINCT client_key)` is **forgeable upward** — a caller varying its User-Agent mints unlimited keys — so it is never labelled "number of consumers".

**Retained query text** is written only on the no-match path (a database CHECK, not a convention), scrubbed for secret shapes at write time, capped at 200 characters by the database, nulled at 30 days, and displayed only once **≥3 distinct callers** have asked it. That threshold does double duty: k-anonymity over text that may describe someone else's project, and the anti-poisoning control without which the highest-value panel is "attacker-supplied text ranked by attacker-controlled frequency". **The private lane (§B2) diverges on every point of this paragraph, deliberately** — query text on every outcome, an answer column, no pass-1 nulling, no display threshold — because it is bearer-gated to exactly one caller: the operator, whose own text needs neither anonymity from themselves nor protection from their own poisoning.

**Rate-limited requests write nothing.** The limiter exists to make refusal cheap; a row per refusal would invert it into an amplifier, turning a flood into an equal number of inserts. A daily write budget bounds the worst case, because the limiter is a *rate* cap and not a *volume* cap.

Retention: `jobs/mcp-telemetry-retention.ts`, weekly. Both passes use absolute-age predicates, so cron jitter is irrelevant and a missed week self-repairs — do not "optimise" either into a since-last-run window. On a weekly cadence the effective windows are **30–37** and **90–97** days; document the upper bound, not the nominal.

## Security posture

Both endpoints live under `/api/mcp/`, which is **CSRF-exempt** (`src/lib/server/security/csrf.ts`) — like `/api/webhooks/` and `/api/cron/`, they carry their own auth model (bearer for admin, unauthenticated read-only for public) and take no ambient cookie credential, so cookie-CSRF is moot; a non-browser MCP client also can't send the `X-Requested-With` header the global check requires.

- **Bearer brute-force resistance:** both bearer endpoints (admin, private) run the IP rate limiter *before* the bearer check, and GET is an unauthenticated 405, so failed-credential attempts are throttled and there is no unthrottled guessing path. Pair that with high-entropy `MCP_ADMIN_TOKEN` / `MCP_PRIVATE_TOKEN` (below). The realms are isolated: each verifier reads only its own env var, and a valid token for one surface is a plain 401 on the other.
- **Constant-time token compare over fixed-length digests:** both the candidate and configured token are reduced to a SHA-256 digest before `timingSafeEqual`, so there is no length branch to leak; the token/digest are never logged or echoed.
- **DNS-rebinding protection:** the `Origin` header is validated on every POST. A request with no Origin (a non-browser MCP client) is allowed; an **exact same-origin** browser request (scheme + host + port) is allowed — a cross-scheme same-host request is rejected (an `http://` Origin never passes for an `https://` deployment); anything else is rejected with 403 unless explicitly listed in `MCP_ALLOWED_ORIGINS`. The bare-host / any-scheme match applies only to explicit allowlist entries, never to the implicit same-origin check.
- **Envelope + protocol validation:** malformed JSON-RPC (`jsonrpc` ≠ `"2.0"`, non-scalar `id`) never dispatches a tool; an unsupported `MCP-Protocol-Version` header is a 400.
- **Bounded tool errors:** a tool that throws is logged server-side and returns a generic MCP tool error — raw exception strings, SQL text, and internal paths are never returned to callers.
- **Version disclosure is coarse on the public surface:** the unauthenticated endpoint reports only `app` + `patternRegistry` version, never the exact commit SHA / environment (that full build info is on the authenticated admin surface only).
- **Attribution is unspoofable:** the admin machine actor is a server-side constant and the client IP comes from the platform-trusted `getClientAddress()`, not a client header; tool argument schemas accept no actor field.

## Environment variables

| Variable | Purpose |
|---|---|
| `MCP_ADMIN_TOKEN` | Bearer credential for `/api/mcp/admin`. Set in the server environment (local `.env` / Vercel project env) — never committed. Must be a high-entropy random secret (≥ 32 bytes / 256-bit, e.g. `openssl rand -base64 32`). Unset → the admin endpoint returns 503. |
| `MCP_PRIVATE_TOKEN` | Bearer credential for `/api/mcp/private` — a separate realm from the admin token, same entropy requirement, same 503-when-unset behaviour. Held only by the operator's own projects; it is the switch that turns full self-telemetry on. |
| `MCP_ALLOWED_ORIGINS` (optional) | Comma-separated extra origins allowed to POST (beyond same-origin and no-Origin clients). Only needed for a trusted cross-origin browser client. An entry may be a full origin (`https://app.example`, scheme-pinned) or a bare host (`app.example`, which matches any scheme for that host). |
| `VERCEL_GIT_COMMIT_SHA`, `VERCEL_ENV` | Auto-provided by Vercel; surfaced in the **authenticated** admin build metadata so an operator can identify the exact deployed commit. |

Names only — no values are recorded here or should ever be committed.

## Registering with Hermes

```bash
hermes mcp add v10r-public --url https://<deployment>/api/mcp/public
hermes mcp add v10r-admin --url https://<deployment>/api/mcp/admin --auth header
hermes mcp test v10r-public
hermes mcp test v10r-admin
```

**Unresolved operator step:** the admin endpoint expects `Authorization: Bearer <MCP_ADMIN_TOKEN>`. How Hermes's `--auth header` flag stores or attaches that bearer token (interactive prompt, config file, or otherwise) cannot be verified from this repository. The operator must configure Hermes to send `Authorization: Bearer <token>`; the exact `--auth header` storage mechanism is a Hermes-side detail this repo doesn't determine.

## Local testing

In-container, targeted (the host has no `node_modules`; run via podman with the `localhost/v10r` image, repo mounted at `/app`):

```bash
bun run test mcp          # dispatch/auth/protocol, SDK interop, demo-service, real-Postgres (PGlite) CAS, page-load, parity
bun run check              # svelte-check
bunx @biomejs/biome ci .   # lint/format gate
bun run mcp:validate       # registry drift guard
bun run mcp:excerpts:check # excerpt snapshot drift guard (fails if a referenced file moved/changed)
bun run i18n:check-missing
```

The SDK interoperability test (`src/lib/server/mcp/sdk-interop.test.ts`) drives a real `@modelcontextprotocol/sdk` client over HTTP against a local adapter that delegates to the same `respondToMcpPost` production uses. The concurrency test (`src/lib/server/mcp/demo/service.pglite.test.ts`) runs against a real Postgres (PGlite) and forces a mid-operation CAS collision to prove the retry returns the true predecessor.

Manual curl against a dev server (`bun run dev`, `http://localhost:5173`):

```bash
# public — no auth
curl -sX POST localhost:5173/api/mcp/public \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# admin — needs MCP_ADMIN_TOKEN
curl -sX POST localhost:5173/api/mcp/admin \
  -H "authorization: Bearer $MCP_ADMIN_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"set_mcp_page_color","arguments":{"color":"red"}}}'

# admin without a token returns 401

# private — needs MCP_PRIVATE_TOKEN; the workspace header labels the row
curl -sX POST localhost:5173/api/mcp/private \
  -H "authorization: Bearer $MCP_PRIVATE_TOKEN" \
  -H 'x-v10r-workspace: densho' \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_patterns","arguments":{"query":"background jobs"}}}'
```

`mcp.demo_state` must exist for live reads/writes: `bun run db:push` provisions the `mcp` schema/table. That command mutates the shared Neon DB and was **not** run as part of this change — running it is an operator step.

The local stdio server is unchanged; its own tests still run via `bun mcp/smoke.ts` and `bun test mcp/server.test.ts`.

## Deployment configuration

Nothing described here has been deployed by this change — this section is documentation for the operator step that follows.

- Set `MCP_ADMIN_TOKEN` as a strong random secret in the Vercel project env (Production and Preview). Never in source.
- Run `bun run db:push` against the target database to create `mcp.demo_state` (`'mcp'` is already in `drizzle.config.ts`'s `schemaFilter`).
- **All six public tools work in the Vercel artifact.** `get_file_excerpt` reads from `mcp/public-excerpts.snapshot.json`, which is statically imported and therefore inlined into the serverless function bundle — no runtime filesystem access, so there is no "may not work in production" caveat. If a registry-referenced file changes, regenerate the snapshot with `bun run mcp:excerpts:build` and commit it; `mcp:excerpts:check` (wired into `bun run validate`) fails the gate if it is stale.
- No secrets are committed. Do not deploy from this doc alone — deployment is a separate, explicit operator action.

## Where to go next

- **The local stdio server this hosted surface parallels:** [pattern-mcp.md](./pattern-mcp.md)
- **Local server operations (run/test/register/add a pattern):** [mcp/README.md](../../../mcp/README.md)
- **The pattern this system follows for sharing domain logic across clients:** [multi-client-core.md](./multi-client-core.md) — the demo-state service is called identically by the admin MCP tools and the admin page, the same shape as every other domain module in this repo
