Threat model for the public Streamable-HTTP deployment at mcp.loopclub.xyz. Last reviewed 2026-06-04.
No-auth is defensible here — but "no security issues" was too glib. The
confidentiality and integrity story is genuinely strong: the server holds no
keys, signs nothing, writes nothing, and reads no chain. There is nothing to
steal and nothing to corrupt. What going public does introduce is an
availability / abuse surface that the stdio build never had — a free,
anonymous, internet-facing compute endpoint. That's a real risk class, and it's
the one this review (and the hardening in src/http.ts + src/schemas.ts)
addresses. With those controls plus Cloudflare at the edge, shipping no-auth is
reasonable. Auth becomes worth adding only if/when the server stops being a pure
encoder (see §8).
| stdio build (today) | remote HTTP build (this) | |
|---|---|---|
| Reachable by | the one local user who launched it | anyone on the internet |
| Authenticated | implicitly (it's your own process) | no |
| Isolation | OS process boundary | a public TCP port (behind a proxy) |
| Trust of input | your own Claude | arbitrary, hostile |
| Blast radius of a bug | your machine | the shared mcp.loopclub.xyz host |
The trust boundary moved from "my laptop" to "the public internet." Everything below follows from that one shift.
This is the crux of why no-auth is tenable. Walk the usual targets:
- Private keys / wallets — none. The server never touches a key or signs. ✓
- On-chain writes — none. It makes zero chain calls; it only encodes a link the user later signs in the app. ✓
- Secrets / API tokens / env credentials — none required to run. ✓
- User data / PII — none collected, none stored. Stateless. ✓
- Database / filesystem — none. Nothing is persisted. ✓
- Outbound network (SSRF risk) — the server makes no outbound requests, so it can't be used as an SSRF pivot. ✓
The only assets are availability (the box stays up, doesn't get used as free
compute) and the reputation of the loopclub.xyz domain (don't let it be a
spam/abuse origin). Those are the things the controls protect.
A public, unauthenticated endpoint is free compute. Even a pure function can be abused. Sub-vectors and mitigations:
| Vector | Why it bites | Mitigation | Where |
|---|---|---|---|
Unbounded input arrays — tracks: [×millions], huge steps/notes |
encode() does O(input) work; the output wire is bounded but the processing isn't |
.max() caps: 32 tracks, 16 steps, 16 notes, 120-char name |
schemas.ts |
| Large request body | balloons the JSON parser + decoder before validation | 64 KB hard cap, aborts mid-stream (stops buffering, flushes a clean 413) | http.ts readBodyCapped |
base64 decode allocation in describe_loop |
fromLink allocates ~0.75× the input length before unpack rejects |
link capped at 4096 chars (schema) + body cap |
schemas.ts / link.ts |
BigInt blow-up via pattern/synthData strings |
huge bigints → slow ops | bigint strings capped at 128 chars; decode only reads bits 0–143 regardless |
schemas.ts / codec.ts |
| Connection exhaustion (held-open SSE streams) | stateful MCP can keep GET streams open per session | stateless + enableJsonResponse → no SSE, no sessions, no per-session memory |
http.ts |
| Slow-loris / hung handler | sockets held open to exhaust the pool | headersTimeout + requestTimeout + per-request setTimeout (15 s) |
http.ts |
| Volumetric flood | raw request rate | coarse per-IP fixed-window limiter (120/min, swept) + Cloudflare WAF (primary) | http.ts + edge |
| Box-level resource exhaustion | a flood that slips through still shouldn't take the host down | systemd MemoryMax=256M, TasksMax, CPUQuota |
deploy/*.service |
The decode path itself (unpack in link.ts) was already written as hostile-input-hardened: it validates length, version, synthCount ≤ 16, and bounds every cellId/midi. The new caps protect the encode path and the allocation-before-validation window.
There is no identity to spoof because there is no auth and no per-user state.
Anyone can call build_loop; the worst outcome is they get a link — which they
could also have produced by reading the open-source codec. No privilege is
gained by calling the server. (See §8 for the explicit no-auth decision.)
The server is a deterministic pure function (spec → link). There is no state
to tamper with and no stored data to alter. A caller can only influence their
own response.
- No secrets exist to leak.
- Error messages: handlers return
(e as Error).message— these are loopgen's own validation strings (e.g. "bad midi 200"), not stack traces or paths. The HTTP layer returns generic JSON-RPC errors for transport faults and logs details to stderr only. ✓ - Open-redirect check: the emitted deep link's origin is server-config
controlled (
LOOPCLUB_ORIGIN), never user-controlled. A caller cannot make the server mint a link to an attacker domain. ✓
The process performs no privileged operations. Defense in depth via systemd:
NoNewPrivileges, ProtectSystem=strict, ProtectHome=read-only,
PrivateTmp, restricted address families/namespaces. Runs unprivileged.
Classic local-MCP attack: a malicious web page resolves a name to 127.0.0.1 to
reach a local server. Mitigated by a Host-header allowlist (mcp.loopclub.xyz
- localhost → otherwise 421) and an Origin allowlist (absent Origin, as real MCP clients send, is allowed; a present foreign Origin → 403). So no browser on a foreign page can drive the server.
The server executes nothing and calls no model — it returns data (a link,
an ASCII grid, per-track text). The one place user text is echoed back is the
optional name field (into the share copy / encoded link). That's bounded to
120 chars and is data, not an instruction; the consuming Claude should treat
tool output as untrusted content (standard MCP hygiene). Low risk, noted for
completeness.
Runtime deps are minimal: @modelcontextprotocol/sdk (+ its @hono/node-server
for the Node HTTP shim) and zod. No new dependency was added for the remote
build — the HTTP server is raw node:http. Keep the SDK patched; pin via
lockfile; enable Dependabot/npm audit in CI.
Two layers, deliberately:
- In-process (
src/) — correctness-critical bounds that must hold even if the edge is bypassed: input caps, body cap, host/origin checks, timeouts, stateless mode, the coarse rate limiter. - Cloudflare edge (must be configured) — the primary volumetric defense:
- TLS termination + hides the origin IP (origin binds 127.0.0.1, tunnel-only).
- WAF + rate-limiting rule on
mcp.loopclub.xyz(e.g. N req/min/IP). - Bot Fight / managed challenge for obvious abuse.
- (Optional) a request-size limit at the edge so floods never reach origin.
The in-process limiter keys on CF-Connecting-IP; it is a backstop, not the
main control. Do not ship without the Cloudflare rate-limit rule enabled.
Ship no-auth, with conditions. Rationale:
- The MCP spec recommends OAuth for remote servers primarily to protect privileged or stateful tools. This server has neither — calling it grants no capability the caller didn't already have (the codec is open source).
- Auth would add real friction to the very thing we want (one-click connector for claude.ai users) for no confidentiality/integrity gain.
- The residual risk (abuse/DoS) is better handled by rate-limiting + WAF than by auth, which doesn't stop a determined abuser who can mint credentials.
Revisit and add auth (OAuth 2.1 per MCP spec) the moment any of these become true:
- A tool gains side effects (writes a DB, reserves cells, calls the chain, spends money, sends email).
- You want per-user quotas/analytics or to attribute usage.
- Abuse persists despite edge rate-limiting and you need accountable identity.
- Anonymous abuse within limits. Someone can still burn your rate budget generating junk links. Impact: wasted compute, not data loss. Mitigation: edge limits; alert on sustained 4xx/429 spikes.
- In-process rate limiter is per-instance and memory-based. Fine for a single instance behind one tunnel; if you ever run multiple replicas it won't be shared (use the edge limiter as the source of truth).
- No request logging/audit by default (deliberate — no PII). If you later need abuse forensics, add structured access logs at the edge, not the origin.
@hono/node-serveris pulled transitively by the SDK's Node HTTP wrapper. It's reputable, but it's surface area; track it in audits.
-
npm run build && npm testgreen (18 tests incl. input-bound tests). - Service bound to
127.0.0.1only (MCP_BIND_HOSTdefault) — confirm it is not reachable on the public IP directly. -
MCP_ALLOWED_HOSTSincludesmcp.loopclub.xyz; verify a bad Host → 421. - Cloudflare tunnel ingress for
mcp.loopclub.xyz→127.0.0.1:8787. - Cloudflare rate-limiting rule enabled on the hostname.
- systemd unit installed with the resource caps;
Restart=on-failure. -
/healthzreturns 200 through the tunnel;GET /mcp→ 405; oversized body → 413; foreign Origin → 403. - Add the connector in claude.ai (
https://mcp.loopclub.xyz/mcp) and run abuild_loopend-to-end.
Remote MCP at
mcp.loopclub.xyz— stateless pure encoder, no-auth by design (no keys/chain/state to protect); DoS/abuse bounded in-process (input caps, 64 KB body cap, host/origin allowlist, timeouts, rate limit) and at the Cloudflare edge.