Server security
What the MCP server can and cannot do with a request, how keys are stored, and the headers the site sends.
The MCP server answers questions out of a Postgres corpus. It does not fetch URLs you give it, run anything, or read a filesystem. The paragraphs below say how that is enforced rather than asserting it.
The server is read-only, with one exception
Seven of the eight tools run select queries and format the result. submit_trap is the only write, and it inserts one row into the submissions queue for a reviewer; it cannot publish anything, and it needs a key. Nothing an agent sends can change a verdict, a trap, or another organization's data.
The server makes no outbound HTTP request at all. Fetching happens in a separate importer process, and every URL it fetches is a fixed registry host written in the code, never a value read from a database row or a submission, so there is no request an agent can make that turns into a request somewhere else.
Every argument is bounded
The JSON body is capped at 64 KB before Express parses it. Each argument then has a length and a shape, checked by zod before a handler sees it.
| Tool | Bounds |
|---|---|
recommend | task 1 to 300 chars, constraints up to 8 strings of 60 |
compare | options 2 to 4 strings of 60, context up to 200 |
alternatives | to up to 80, kind one of open_source, self_hosted, any |
traps | library up to 80, version up to 40, id up to 64 |
skill | slug up to 120 |
design | screen up to 200, constraints up to 8 strings of 60 |
docs | library up to 80, topic up to 120 |
submit_trap | symptom up to 400, fix up to 1200, evidence_url up to 300 and http or https only |
Search terms are escaped for %, _ and \ before they reach a like, so a query of % matches a literal percent sign instead of scanning the table.
Keys
A key is a random high-entropy value stored as its SHA-256 hash, so the database holds nothing that can be replayed. Creating, listing, renaming and deleting a key all resolve your session first and then check that you are a member of the organization the key belongs to, which is why the organization id the dashboard sends cannot name someone else's.
An unrecognized key is refused with 401 rather than being treated as an anonymous caller, so a typo fails loudly on the first call.
Rate limits, at two layers
Cloudflare sits in front of the endpoint and blocks abusive traffic at the edge; our security policy asks you not to load test it. Behind that, one Lua script in Redis runs the published limits in a single round trip: the burst counter over 60 seconds, the window counter that expires at the end of the day or the month, and for no-key callers a set of addresses within a /24 that have hit the cap, which folds a whole noisy network into one allowance once more than ten of its addresses run out.
Forwarded address headers are only read when TRUST_PROXY is set, which the production stack does because Cloudflare's tunnel writes cf-connecting-ip on every hop. Without it a caller could mint a new address per request and get an unlimited number of free daily allowances.
Errors say nothing useful to an attacker
A driver or database error carries the connection string in its message, so tools answer with a short reference id and the real error goes to the log. /health answers { ok: false } and logs the rest.
Origin and headers
No host port is published on the server. The internet reaches the services only through a Cloudflare tunnel on an internal network, and every container runs as an unprivileged user with the Postgres password read from a Swarm secret file rather than an environment variable.
The website sets a Content Security Policy with a fresh nonce per request:
default-src 'self'; script-src 'self' 'nonce-…' https://static.cloudflareinsights.com https://cdn.paddle.com;
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:;
connect-src 'self' https://cloudflareinsights.com https://static.cloudflareinsights.com https://cdn.paddle.com;
frame-src https://buy.paddle.com; form-action 'self'; frame-ancestors 'none'; base-uri 'self';
object-src 'none'; upgrade-insecure-requestsstyle-src keeps 'unsafe-inline' because React writes every style prop as a style attribute, which a nonce cannot cover. 'strict-dynamic' is absent on purpose: it makes browsers ignore the host list, and the Cloudflare analytics beacon is injected at the edge after this header is written. X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin go out with it; HSTS is set at Cloudflare.
Session cookies are httpOnly and SameSite=Lax. Sign-in and sign-up accept three requests per ten seconds per address, password reset and verification mail three per minute, and every other auth route a hundred per ten seconds.
The last review
A full read of the tree was done on 8 September 2026: every route handler, server action, query and importer, plus semgrep over apps and packages and a production dependency audit. It found and fixed a forwarded-address bug in the anonymous limiter, an auth rate-limit bucket that a header could collapse, four unescaped like patterns, a shell command built from an environment variable in the catalog importer, and a webhook that trusted a repository name. Four items were written up as open rather than fixed, including in-memory auth rate-limit storage that resets on deploy. The report lives in the private repository at docs/security/review-2026-09-08.md; ask security@wellworn.dev if you need a copy.