MCP server
The Model Context Protocol (MCP) is an open standard that lets AI assistants call tools on remote servers. This platform hosts one, so an assistant connected to it can answer "how did the pricing page do last month?" or "set up a checkout funnel" by calling the same endpoints documented in the REST reference — no glue code, no scraping.
Endpoint
https://analytics.appfor.you/api/mcpAuthentication
Authorization: Bearer <your API key>- Transport: Streamable HTTP, JSON-RPC 2.0, stateless. No Mcp-Session-Id is issued and no SSE stream is opened, so every request must carry its own `Authorization: Bearer <api key>` header.
- Supported methods: initialize, notifications/*, tools/list, tools/call, ping. Protocol revisions 2025-06-18, 2025-03-26 and 2024-11-05 are all accepted.
- `tools/list` WITHOUT a `params.cursor` returns the COMPLETE registry in one response and no `nextCursor`. Pagination is only a SHOULD in the MCP spec, so a client that ignores cursors must not be left believing a first page is the whole server. Supply a cursor and the listing pages deterministically at 20 tools per response: pass each `nextCursor` back verbatim as `params.cursor` until a response arrives without one. A cursor this server did not issue is rejected with JSON-RPC -32602 instead of silently restarting the listing.
- JSON-RPC BATCHES are capped at 20 messages; a longer array is rejected with -32600 before any element is dispatched. That check runs AFTER transport authentication, so an oversized batch has still cost one API-key lookup and one unit of the rate-limit budget — a cheap rejection, not a free one. Elements run at most 4 at a time and responses are returned in request order.
- The first element of a batch rides on the unit the transport already charged; every further element charges its own unit before it runs. If an element is denied mid-batch it comes back as JSON-RPC error -32000 carrying the retry timestamp, while the elements that already succeeded still return their results.
- Tool results are SIZE-CAPPED, and never silently: the text of a successful result is truncated at 24,000 bytes, and an upstream error body echoed inside a tool error at 2,000 bytes. A truncated result ends with the marker `--- MCP_RESULT_TRUNCATED ---` — treat that as "there is more", and re-run the tool with a smaller `limit`, an `offset` or a narrower date window rather than acting on the partial payload.
- GET /api/mcp returns 405 with an `Allow: POST, OPTIONS` header; OPTIONS returns 204. CORS allows Accept, Mcp-Session-Id, MCP-Protocol-Version and Last-Event-ID, and exposes WWW-Authenticate, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and Retry-After.
- Authentication is a static API key — the same key you use for REST, with the same scopes. There is no OAuth flow and no /.well-known/oauth-protected-resource document. A 401 carries `WWW-Authenticate: Bearer realm="analytics-platform", error="invalid_token"`. Every response produced by the protocol layer — JSON-RPC results, JSON-RPC errors and the 202 for a notification alike — carries the `X-RateLimit-*` headers, as does a 429; the 401 and 503 raised before that layer runs do not.
- Tools are never more permissive than REST: each tool re-enters its own /api/v2 endpoint over loopback HTTP, so every ownership, plan and viewer rule is evaluated exactly once by the route that owns it. The loopback origin comes from server configuration only — never from the request’s Host or X-Forwarded-* headers — and the target must resolve to that origin under /api/v2/.
- Rate limiting is shared with the REST API. A tools/call consumes TWO of the 100 req/min budget (once at the MCP transport, once inside the v2 route); initialize, ping and tools/list consume one; each further element of a batch consumes one more.
- All eight `delete_*` tools are destructive: they require the `admin` scope AND a `confirm` argument equal to the id of the resource being deleted, and `confirm` is declared REQUIRED in the tool schema. It is validated in the MCP layer and again by the REST route. Supply it only after a human has explicitly asked for that deletion.
Connect a client
Replace YOUR_API_KEY with a key from Dashboard → Settings → API Keys. Give it the narrowest scope you need: read for analysis, write to let the assistant create goals and funnels, admin only if you genuinely want it able to delete things.
Claude Code
Add the server from your terminal — one command, no config file to edit:
claude mcp add --transport http analytics https://analytics.appfor.you/api/mcp \ --header "Authorization: Bearer YOUR_API_KEY"
Then run /mcp inside Claude Code to confirm the server is connected and see its tools.
Claude Desktop
Edit claude_desktop_config.json (Settings → Developer → Edit Config) and add an entry under mcpServers:
{
"mcpServers": {
"analytics": {
"type": "http",
"url": "https://analytics.appfor.you/api/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}Restart Claude Desktop after saving.
Cursor
Add the server to .cursor/mcp.json in your project (or ~/.cursor/mcp.json for every project):
{
"mcpServers": {
"analytics": {
"url": "https://analytics.appfor.you/api/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}Cursor picks the file up automatically; check Settings → MCP for a green status dot.
Any MCP client (raw JSON-RPC)
The transport is Streamable HTTP and stateless — every POST carries its own credentials and gets a plain application/json reply. You can drive it with curl:
curl -X POST https://analytics.appfor.you/api/mcp \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Once connected, every tool the server offers is listed in the MCP tool reference — 55 of them, each mapping onto the REST endpoint of the same name.
Self-hosting note
Only relevant if you run your own instance. Each tool re-enters the REST API over loopback HTTP, and the origin it calls is taken from configuration alone — never from the request's Host or X-Forwarded-* headers, which a caller could otherwise use to redirect an authenticated internal call. It resolves in this order:
- MCP_INTERNAL_ORIGIN — an absolute http(s) URL. Only its origin is used; any path, query or embedded credentials make the value invalid and it is skipped.
- http://127.0.0.1:$PORT (PORT defaults to 3000) — used when the variable above is unset or invalid. There is no further fallback; NEXTAUTH_URL is not consulted.
Nothing needs setting in a normal single-container deployment: loopback is the default target, so the call never leaves the container or traverses the reverse proxy. Set MCP_INTERNAL_ORIGIN only when the app is not reachable on 127.0.0.1 at its own PORT — for example when /api/v2 is served by a different container, or behind an internal service hostname.
MCP_INTERNAL_ORIGIN=http://127.0.0.1:3000
Deployment environment variables
All 10 variables are optional and default safely; none of them changes the public API contract.
| Variable | Default | What it does |
|---|---|---|
MCP_INTERNAL_ORIGINoptional | http://127.0.0.1:$PORT (PORT defaults to 3000) | Absolute http(s) URL pinning the origin the MCP server uses for its loopback calls into /api/v2. Only the origin is used; any path, query or embedded credentials make the value invalid and it is skipped. Resolved from configuration only — never from the request’s Host or X-Forwarded-* headers, which a caller could otherwise use to redirect an authenticated internal call. |
TRUSTED_PROXY_HOPSoptional | 1 — correct for a single reverse proxy (Traefik/nginx) directly in front of the app | How many proxies between the internet and this process APPEND to `x-forwarded-for`. The per-IP failed-authentication bucket is keyed on the entry that many positions from the END of the header, so getting it wrong only degrades failed-auth bucket accuracy — it never affects authentication, authorization or any endpoint result. The geo resolver (src/lib/geoip.ts) consults the same variable, but only when `GEO_TRUST_PROXY` is enabled — on the default Netlify deployment the pinned edge headers answer first and this value is never reached. Set it to 2 if a CDN is later placed in front of the reverse proxy. Absent, non-numeric or < 1 values fall back to 1 (the geo resolver additionally treats values > 16 as invalid). |
TRUST_X_REAL_IPoptional | false — x-real-ip is ignored and the request falls into the shared unknown bucket | Set to true ONLY when a proxy you operate overwrites `x-real-ip` on every inbound request. It is consulted for failed-authentication bucketing and by the geo resolver (src/lib/geoip.ts), in both cases only when the `x-forwarded-for` chain yields nothing usable. It stays off by default because `x-real-ip` is otherwise ordinary client input: a caller could set it to a victim address to pin that address to its bucket, or rotate it per request to evade the budget entirely. |
GEO_EDGE_PROVIDERoptional | netlify — correct for this repo’s Netlify deployment | The single CDN whose edge geo / client-IP headers the geo resolver believes: one of netlify | cloudflare | vercel | none. Exactly one provider is trusted per deployment — an edge header is only trustworthy because the CDN in front of the origin overwrites it on every request, so any other provider’s headers are ordinary client input and are ignored completely. Unrecognized values are treated as none (with a one-time warning), never as “trust everything”. If the site ever moves behind a different CDN, change this in the same deploy — getGeoHealth() in src/lib/geoip.ts exposes pinned-vs-observed provider counters to catch the deploy where that was forgotten. |
GEO_TRUST_PROXYoptional | false — x-forwarded-for is ignored by geo resolution | Whether the geo resolver may believe `x-forwarded-for` at all when the pinned edge provider’s headers are absent. Off by default: on Netlify the CDN terminates every request and writes the edge headers, so XFF adds zero coverage while its left entries are attacker-controlled text. When enabled, the entry `TRUSTED_PROXY_HOPS` positions from the END of the chain is selected, then walked leftward past private/reserved addresses to the first public one. Affects geo resolution and the /api/collect rate-limit / bot-detection address only — never authentication. |
MAXMIND_LICENSE_KEYoptional | unset — the updater logs a loud banner, keeps the bundled GeoLite2 snapshot and exits 0 (a deploy never fails for lack of a key) | Free MaxMind GeoLite2 licence key used by scripts/update-geoip.mjs (hooked into every build via the npm `prebuild` script) to refresh geoip-lite’s IP database, which otherwise stays frozen at the package’s npm publish date. Set it on the Coolify application (Configuration → Environment Variables), never in the repo — this deployment is Coolify + Nixpacks + Traefik and has not been on Netlify for some time, so the instruction that used to stand here named a settings page nobody can open; the refreshed .dat files are likewise never committed — GeoLite2 redistribution is licence-restricted, so the pipeline regenerates them per build. DB age is observable at runtime via getGeoHealth(). |
SITE_CLAIM_ALLOW_LOOPBACK_PROOFoptional | false — the ownership-proof fetcher refuses loopback destinations, in every environment including development | Affirmative opt-in allowing the site-ownership proof fetcher (POST /api/sites/claim) to dial a LOOPBACK address, so a developer can claim a site served from their own machine. Every other reserved range — 10/8, 172.16/12, 192.168/16, 169.254.169.254, CGNAT, TEST-NET, NAT64 and IPv4-mapped spellings of all of them — stays refused whatever this is set to, and the resolved address is pinned before connect so DNS cannot be rebound between the check and the socket. It is deliberately an opt-in rather than a “not production” default: gating it on the ABSENCE of NODE_ENV=production meant a deployment that merely forgot to set the production marker silently gained a loopback fetcher, which is both a narrow existence oracle and a way to satisfy a claim from a local service instead of the real public domain. Absence of a marker must never grant a capability. Ignored (treated as false) in production and whenever `SITE_CLAIM_REQUIRE_PROOF_ALWAYS` is on. A claim satisfied this way is reported with `proof.development: true` so it can never be mistaken for a production-grade proof. |
SITE_CLAIM_REQUIRE_PROOF_ALWAYSoptional | false — production semantics still apply automatically when NODE_ENV=production | Forces production ownership-proof semantics in a non-production process: no loopback destinations and no development exceptions, exactly as if NODE_ENV were production. Set it in staging so staging behaves like production. It can only ever make the proof stricter — there is no value of this variable, or of any other, that relaxes the reserved-range refusals. |
SITE_CLAIM_IP_PEPPERoptional | NEXTAUTH_SECRET, then a fixed development string if that is unset too | HMAC key used to key the client address stored against each unclaimed site, which is how the per-address cap on outstanding unclaimed sites is enforced without turning an anonymous endpoint into an IP log. A plain hash would not do: the IPv4 keyspace is small enough to brute-force exhaustively, and a keyed one is not. Set it to an independent secret if you would rather the cap not share key material with session signing; the fallback to NEXTAUTH_SECRET means a normal deployment needs no new configuration. |
MONGODB_AUTO_INDEXoptional | off when NODE_ENV=production, on otherwise — Mongoose’s own default (always on) is deliberately not used | Whether Mongoose may build a collection’s declared indexes at runtime, on the first query against each model. Off in production because it is a schema write performed by an ordinary request: a schema whose index declaration is wrong silently rewrites the live indexes on every restart — that is exactly how a corrected RevenueEvent orderId index was recreated in its broken form, discarding revenue events again — and a build on a large collection runs during a cold start at the cost of request latency. It also cannot converge a database on the schema even when left on, because it only ever CREATES indexes and never drops one a schema stopped declaring; index changes are applied deliberately by the migrations in scripts/ instead. Accepts true/1/yes/on and false/0/no/off; set it to true for the single deploy where an operator does want a build, and unset it afterwards. It never affects query results — only which indexes exist to serve them. |
The rate limiter also needs a writable ratelimits collection; it is created on first use and kept small by a TTL index, so there is nothing to provision.