How I built a self-hosted analytics MCP server on Cloudflare Workers and D1
I wanted to ask my coding agent "why did traffic jump yesterday?" and get an answer with numbers, not a link to a dashboard. So the analytics behind measuremy.site expose everything over MCP. I've now published a single-site version as open source: agentlytics-mcp, MIT licensed, which you deploy to your own Cloudflare account. This post walks through how it works.
The pieces
- A tracker (
/t.js, under 1 KB) that sends pageviews, single-page-app navigation and custom events withnavigator.sendBeacon. - A collect endpoint on a Cloudflare Worker that writes one row per hit to D1.
- An hourly cron that rolls raw events into hourly totals, finds spikes and deletes old events.
- An MCP endpoint (
/mcp) that your agent calls with a bearer key.
Everything runs on Workers and D1, so there is no server to patch and the free tier covers most small sites.
Counting visitors without cookies
Nothing is stored in the browser. To tell visitors apart within a day, the worker computes a hash:
salt = HMAC(HASH_SECRET, day number) visitor = HMAC(salt, site id | IP address | user agent)
Only the hash is stored, never the IP address or user agent. Because the salt changes every UTC day, the same person gets a different id tomorrow, so nobody can be followed across days. The cost is real: "unique visitors this month" isn't something this design can answer, and a funnel only counts a visitor when every step happens on the same day. For a small site deciding what to post next, daily uniques are enough.
The worker also keeps the browser and OS names ("Firefox", "macOS"), the country Cloudflare reports, the page path, the referring host and any UTM tags. Hits from other domains are rejected, so nobody can fill your analytics from elsewhere.
Hourly rollups
Every hour the cron rebuilds the last 48 hours of a rollup_hourly table: total pageviews, and pageviews per source, page and event, each with distinct visitors. Rebuilding a trailing window absorbs late events, and the upsert only rewrites rows whose numbers changed, which keeps D1 write costs down. Questions about arbitrary filters still go to the raw events table, which is indexed by site, type and time.
Finding spikes
A spike is an hour that is far above what that hour normally looks like. For each hour the detector takes the same hour of the week over the previous four weeks (or the same hour over the previous seven days for a new site) and computes the median and the median absolute deviation. An hour counts as a spike when it is at least four robust deviations above the median, at least 10 pageviews above it, and at least 50% higher. Using the median rather than the mean means one past spike doesn't hide the next one.
Then it explains the spike. For every source, page and event, it compares the spike hour with the same baseline hours, and keeps the ones that rose by a meaningful share of the extra traffic. The result reads like "news.ycombinator.com: +140 pageviews, 92% of the spike; /launch: +120". Notes you leave with the add_annotation tool (a deploy, a post, an email) in the 24 hours before are attached too, so the agent can say "this followed your Reddit post".
The MCP endpoint
The server speaks MCP's streamable HTTP transport, but statelessly: each POST is a JSON-RPC message (or a batch), answered in one response. There are no sessions to store, which suits Workers. It implements initialize, tools/list, tools/call and prompts, and checks one bearer key by comparing hashes.
The tools are deliberately specific rather than one "run SQL" tool:
- Traffic: overview, timeseries, top sources, pages, countries, browsers and systems.
- Questions:
query_visitsfilters and groups by a fixed list of dimensions, with every value bound as a parameter, so an agent can ask "iOS visitors from the US by source" but can never send SQL. - Spikes: find them and explain one.
- Funnels: pages and events as ordered steps, with the biggest drop called out.
- Growth: channels ranked by conversions, and concrete next actions with evidence.
- Campaigns: create a UTM link for each post, then read back its visitors and conversions.
Small, well-described tools with structured JSON results are much easier for an agent to use correctly than raw tables, and they keep a leaked key from doing much damage.
Deploy your own
The README has a Deploy to Cloudflare button that creates the database, runs the migrations and asks for your domain and two keys. Or do it by hand in about five commands. Then add the script tag to your site and connect your agent:
claude mcp add --transport http agentlytics https://YOUR-WORKER/mcp --header "Authorization: Bearer YOUR_KEY"
Want to try the tools before deploying anything? The read-only demo key connects your agent to measuremy.site's own live analytics.
Self-hosted or hosted
The open-source version is one site per deploy and has no dashboard: your agent is the interface. The hosted version at measuremy.site runs the same tools for several sites and adds a dashboard with filters, AI explanations of spikes, tracking of posts that mention you on Hacker News, Bluesky and YouTube, and alerts. Use whichever fits; the data model is the same.
See it on your own site
agentlytics is cookieless analytics your coding agent can read. Free for 5,000 pageviews a month, no card.
Start free See the live demoAdd agentlytics analytics to this project. Follow the instructions at measuremy.site/llms.txt
More from the blog
- UTM tracking explained: what the parameters mean and how to use them
- Conversion funnel analysis for small sites: a practical guide
- Sudden drop in website traffic? A checklist for finding the cause
- What is an MCP server? A plain-English guide for people who run websites
- How to track the results of a social media post without cookies
- How to track traffic from a GitHub README
- How to measure a product launch without guessing