The local stdio Pattern MCP (see 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. 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_excerptis served from a build-time snapshot, not the filesystem. The registry-referenced public files are captured intomcp/public-excerpts.snapshot.json(regenerated byscripts/mcp/build-public-excerpts.ts) and imported statically, so Vite inlines them into the serverless function — nofs, noprocess.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 bymcp: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 runtimefsaccess. The pure tool logic is re-implemented insrc/lib/server/mcp/patterns/because the container-only stdio files undermcp/can't be imported intosrc/(their Bunimport.meta.dirusage breakssvelte-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 (
jsonrpcmust be"2.0";idmust be a string, number, or null); a notification (noid) returns HTTP 202 with no body; malformed JSON returns an HTTP 400 parse error.Originis validated (DNS-rebinding protection, see Security posture) and a present-but-unsupportedMCP-Protocol-Versionheader 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: Bearerheader. 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_historyreturns 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_texton 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 theX-V10r-Workspaceheader, validated to[a-z0-9][a-z0-9-]{0,31}and dropped (never coerced) when invalid. A label, not an identity.trafficis 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 classifiestestand preview deploymentspreview; lane queries filter onsurface, nevertraffic.- 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):
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):messageis plain text, 1–500 chars, no control characters, never interpreted as HTML — stored raw, rendered escaped by Svelte.colormust 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 thebeforein every response is the verified immediate predecessor of itsafter, 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 boundedconflicterror rather than a wrongbefore. - Domain service:
src/lib/server/mcp/demo/service.tsis 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_stateisn'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
Originheader 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 (anhttp://Origin never passes for anhttps://deployment); anything else is rejected with 403 unless explicitly listed inMCP_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-scalarid) never dispatches a tool; an unsupportedMCP-Protocol-Versionheader 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+patternRegistryversion, 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
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):
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):
# 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_TOKENas a strong random secret in the Vercel project env (Production and Preview). Never in source. - Run
bun run db:pushagainst the target database to createmcp.demo_state('mcp'is already indrizzle.config.ts'sschemaFilter). - All six public tools work in the Vercel artifact.
get_file_excerptreads frommcp/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 withbun run mcp:excerpts:buildand commit it;mcp:excerpts:check(wired intobun 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
- Local server operations (run/test/register/add a pattern): mcp/README.md
- The pattern this system follows for sharing domain logic across clients: 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