A Model Context Protocol server that puts GA4, Cloudflare Web Analytics, Vercel Analytics and Google Search Console behind one normalized tool surface — and, more importantly, teaches the model what the numbers mean.
Ask "how did the site do last week?" and every tracker you run answers at once, with the gaps between them explained instead of hidden.
- Remote: StreamableHTTP + OAuth 2.1, deployable to Vercel — usable from Claude web and any remote MCP client.
- Local: the same server over stdio for Claude Code, Cursor, Claude Desktop.
- Explains itself: every metric carries a plain-language meaning, and the server knows which discrepancies between trackers are structural.
There are already good GA4 MCP servers. They share three limits: single source, stdio only, and numbers without meaning. This one is built around the other three answers.
One question, every tracker. GA4 and Cloudflare will never agree on
pageviews. Most tools hide that. query fans out to every source bound to a
site, reports each answer separately, and notes where they diverge — because
the divergence is information, not an error.
The gaps are explained, not averaged. The server codifies why two
trackers differ: one counts in the browser after a cookie banner, another
counts raw requests at the network edge including bots. explain_discrepancy
gives a deterministic verdict on whether a gap is normal, and says plainly when
it has no criterion rather than inventing a number.
Written for someone who has never opened an analytics dashboard. Each
metric ships with a businessMeaning in plain words — "How many times a page
was opened. One person who reads three pages counts three times." — and the
bundled prompts instruct the model to lead with the business answer, never the
metric name. The person reading the answer should not need to know what GA4 is.
┌──────────────────────────────┐
Claude web ───► │ api/mcp.ts (serverless) │ ── OAuth 2.1 + PKCE
├──────────────────────────────┤
Claude Code ───► │ src/index.ts (stdio) │ ── local, no auth
├──────────────────────────────┤
private host ──► │ src/serve.ts (long-lived) │ ── static bearer
└───────────────┬──────────────┘
│ all three call
▼
createServer() ← host-agnostic
│
┌───────────────┼───────────────┐
▼ ▼ ▼
tools + prompts semantics/ + seo/ sources/registry
+ resources knowledge.ts ctr-curve │
(the criterion) │
┌───────────┬─────────┼─────────┐
▼ ▼ ▼ ▼
GA4 Cloudflare Vercel GSC
seo/ and semantics/ are analysis over the same four sources, not sources of
their own — which is why adding SEO needed no new credentials and no change to
the adapter contract. A fifth source (a third-party SEO API, say) would be one
new file in sources/ plus one line in the registry.
Two rules hold this together, and the test suite enforces both:
createServer()imports nothing from Vercel, HTTP or stdio. It cannot tell where it runs, so there is no environment branch to rot.- Environment switching lives in exactly two places — the entry points and
src/sources/registry.ts. An environmentifinside a tool is a bug.
"Remote" means your deployment with your credentials, not a hosted service. There is no multi-tenant mode: you deploy it, you set your own tokens, you are the only tenant. That is a deliberate limit, not a missing feature — analytics credentials read your whole audience, and pooling them behind someone else's service is the wrong trade.
| Tool | What it does |
|---|---|
list_sites |
Configured sites and which trackers cover each |
list_sources |
Which adapters are configured (presence only, never values) |
get_schema |
Metrics and dimensions available per source |
query |
Primary. Normalized query fanned out across every bound source |
query_raw |
Escape hatch: a native payload to one source |
explain_discrepancy |
Is this gap between two trackers normal? Deterministic |
validate_query |
Dry-run: unsupported metrics, truncated ranges, bad comparisons |
seo_opportunities |
Cheapest search wins, ranked by estimated missed clicks |
explain_ctr_gap |
Is one page underperforming its position? Deterministic |
ai_referrals |
Traffic arriving from AI assistants, by engine |
inspect_page |
What one page says about itself: title, description, canonical, headings, status |
page_changes |
What changed on a page and when, with the search numbers either side |
Every tool is read-only. There are no write tools and none are planned.
Canonical metrics: pageviews, sessions, visitors (site-side) and
clicks, impressions, ctr, position (Google Search). Each maps to its
native name per source; a source that cannot answer one returns a warning in
its slot rather than failing the whole query.
Two things usually sold together, kept apart here because only one of them can honestly be measured.
Search. The click-through curve — how much a position normally earns — is computed from your own Search Console rows, never imported. Published CTR-by-position curves swing wildly by industry, language and which SERP features sit above the results; borrowing one would confidently call your pages broken for missing a benchmark they were never subject to. When your own data is too thin around a position, the tools say so instead of inventing an expectation.
Findings are ranked by estimated missed clicks, not by a score out of 100, because a reader can decide on the first and not the second:
position 6.1 · 16,819 impressions · 6 clicks · CTR 0.04%
your other pages at that position earn 0.9% (measured over 33,970 impressions)
→ underperforming, about 146 clicks missed
"The ranking is fine; the invitation is not."
AI visibility. ai_referrals reports sessions that arrived from an AI
assistant, broken down by engine. It states in every response that arrivals are
not citations: no API reports whether an assistant mentioned you, plenty of
people read an answer without clicking, and Search Console folds AI Overview
appearances into ordinary impressions. There is deliberately no "GEO visibility
score" here — it would be a fabricated number wearing a data costume.
inspect_page fetches a page and reports what it says about itself, so a
click-through verdict can name a cause rather than prescribe blind. It is
deliberately narrow:
- Only hosts bound to the site. The allowlist comes from your own
configuration, so no argument can point it at a host you never configured.
A
sc-domain:Search Console property covers its subdomains, because that is what the property covers; every other binding matches exactly. - A bound name still has to point outward. Every address a host resolves to is checked before connecting, so a forgotten subdomain aimed at the internal network is refused even though its name is legitimately yours.
- Redirects are reported, never followed. A 301 on a page that ranks is a finding worth having, and not following it removes a whole class of server-side request forgery at the same time.
- No JavaScript is run. A page that builds its title in the browser reports no title here — which is also what a crawler sees, so the finding is correct even though the page looks fine to you.
- Mechanical findings only. Title length, missing description, duplicate
headings, a canonical pointing elsewhere,
noindex, a non-200 status. Whether the wording deserves the click is a judgement left to the reader.
page_changes is the only tool here that reads something this server wrote
itself. A nightly job records how each page looks and writes only when the
content hash moves, so the store holds a change log rather than a time
series — two or three entries a year for a page nobody edits, instead of 365
identical ones.
That also makes a repeated run harmless, which matters: Vercel documents cron delivery as best effort, able to both miss a run and deliver the same one twice, and it never retries a failure. A duplicate run has to be a no-op.
What is deliberately not stored: metrics. Search Console keeps sixteen months of those and is the system of record; a second, worse copy would be a liability, not an asset. Only page state — which nobody else keeps — is written.
It is optional. With no store configured the tools say they have nothing recorded instead of failing, and everything else works exactly as before.
UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN the store (also used by the cache,
under a separate keyspace)
CRON_SECRET required for the scheduled capture
Locally there is no scheduler, so pnpm capture runs the same job on demand.
Sizing: fifty pages captured daily, written only on change, stays under 2K commands a month against Upstash's 500K free tier.
analytics://metrics— what every metric means, how each source counts it, and how far two sources normally differ.analytics://metrics/{siteId}— the same, plus expectations measured for one site.interpret-query— how to explain results to a non-technical reader.site-report— end-to-end recipe: query every tracker, reconcile the differences, write what happened / what changed / what deserves attention / what to check next.
Requires Node 20+ and pnpm.
git clone https://github.com/karenrebecag/analytics-mcp.git
cd analytics-mcp
pnpm install
pnpm buildSITES_CONFIG is a JSON array mapping a friendly id to per-source
identifiers. See sites.example.json:
[
{
"id": "marketing-site",
"name": "Marketing website",
"sources": {
"ga4": { "propertyId": "123456789" },
"cloudflare": { "zoneId": "0123…", "host": "www.example.com" },
"gsc": { "siteUrl": "sc-domain:example.com" }
}
}
]Each source accepts an optional host to scope it to one subdomain — a GA4
property and a sc-domain: Search Console property both cover every subdomain
at once, so without it a site named blog.example.com would report the whole
estate under that label.
A site needs only the sources you actually run. Optionally add an
expectations block to record the normal gap between two trackers for that
site, which overrides the generic criterion.
Copy .env.example to .env and fill in what applies. Every source is
optional — the server runs with one, or all four.
| Source | Variable | How to get it |
|---|---|---|
| GA4 | GA4_SERVICE_ACCOUNT_JSON |
Service account key (one line). Add its email as a Viewer on each GA4 property. |
| Cloudflare | CLOUDFLARE_API_TOKEN |
Token with Account Analytics: Read. Add CLOUDFLARE_ACCOUNT_ID to read Web Analytics (RUM); without it the adapter reads zone HTTP analytics instead. |
| Vercel | VC_API_TOKEN |
Account settings → Tokens. Not VERCEL_API_TOKEN: Vercel reserves the VERCEL_ prefix and rejects it as invalid on its own deployments. Both names are read elsewhere. |
| Search Console | GSC_SERVICE_ACCOUNT_JSON |
Service account added as a user in Search Console. Leave empty to reuse the GA4 key. |
Cloudflare's two modes matter. With an account id it reads the Web Analytics beacon (browser-side, undercounts like GA4). Without one it reads zone HTTP requests (edge-side, includes bots and assets, routinely several times higher). The server knows the difference and adjusts what it calls a normal discrepancy.
pnpm probeCalls the cheapest real read on every configured source and writes the raw
responses to scratch/ (gitignored). If a source is misconfigured you find out
here rather than mid-conversation. Nothing it does writes to your accounts.
{
"mcpServers": {
"analytics": {
"command": "node",
"args": ["/absolute/path/to/analytics-mcp/dist/index.js"],
"env": { "SITES_CONFIG": "[…]", "GA4_SERVICE_ACCOUNT_JSON": "{…}" }
}
}
}Use an absolute path. Check it without a client:
printf '%s\n%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| node dist/index.jsDeploy the repo, then set the environment variables from .env.example on the
project. Beyond your source credentials you need:
| Variable | Why |
|---|---|
MCP_SIGNING_SECRET |
Signs every token this server issues. Rotating it invalidates all of them. |
FRONTEND_URL |
Your sign-in page — see the contract below. |
UPSTASH_REDIS_REST_URL + _TOKEN |
Auth state. Required in production: without it every token exchange fails closed. Vercel's Upstash integration injects KV_REST_API_*, which is also accepted. |
ALLOWED_EMAIL_DOMAIN |
Optional. Restrict access to one email domain; empty means no restriction. |
CLERK_JWT_KEY |
Optional. Also accept Clerk session tokens as bearers. This is the public PEM (Clerk → API keys → Show JWT public key), so the server never holds a credential that can mint or revoke sessions. CLERK_SECRET_KEY works too, but grants far more than verification needs. |
Then add https://your-deployment/mcp as a custom connector in Claude. The
OAuth flow starts on its own — the 401 carries the WWW-Authenticate header
that triggers it.
This server implements OAuth 2.1 — dynamic client registration, PKCE with S256
pinned, single-use authorization codes, refresh rotation with reuse detection,
and revocation — but it does not authenticate anyone itself. /authorize
validates the request and redirects to FRONTEND_URL, a page you host, which
must:
- authenticate the user however you like — Clerk, your own login, anything;
- mint an authorization code as a JWT signed with
MCP_SIGNING_SECRET, carryingsub, optionallyemail, and echoing backclient_id,redirect_uri,code_challengeandcode_challenge_method, plus a uniquejti. Issuer<MCP_ISSUER>-oauth, audience<MCP_ISSUER>(defaultanalytics-mcp); - redirect back to
redirect_uriwith that code and the originalstate.
Everything after that — verifying PKCE, binding the code to its client, enforcing single use, issuing and rotating tokens — happens here. Keeping the login page outside this repo is what lets the same code serve any identity provider without a fork.
pnpm serve runs a long-lived HTTP server instead of serverless functions.
It is not the default path; it exists so that reaching a source which needs a
machine that stays up is a new adapter plus this entry, not a rewrite.
Auth is a static SERVE_BEARER_TOKEN, meant to sit behind a reverse proxy.
Without a token it refuses to bind anything but loopback — an
unauthenticated MCP server reachable from the network is a data leak, not a
convenience.
Quotas and cache. GA4 charges tokens per property per day. query caches
each successful per-source result for 5 minutes, keyed by a hash of the
normalized request (QUERY_CACHE_TTL_S to change it). On serverless, set
Upstash — an in-process cache dies with the isolate and buys you nothing.
Slow sources degrade, they do not fail. Each source gets its own timeout
(QUERY_SOURCE_TIMEOUT_MS, default 10s). A source that times out becomes an
entry in errors; the rest of the answer still arrives. A missing source means
no data from that source — never zero traffic.
Row caps truncate silently upstream. Cloudflare returns at most 40 daily
rows, Search Console 1000, GA4 10000. validate_query warns before you read
numbers that quietly left data out.
Criterion is self-calibrating. Both the cross-source discrepancy ranges and the CTR curve are derived from your data or from documented mechanism, never from an imported benchmark. Where no criterion exists, the tools return "no criterion recorded" rather than a plausible guess — a wrong number that sounds right is worse than an honest gap.
Adapters are typed against real captures, never against documentation.
pnpm probe first, then the adapter. Docs drift; captured responses do not.
pnpm verify # typecheck + lint + format + unit tests + build gatespnpm gates builds the project and runs tests/gates/ against the compiled
artifact: a small security suite (secret hygiene, PKCE pinning, redirect_uri
allowlist, fail-closed auth state, no logs on the MCP channel) and a
performance suite (cold start, parallel fan-out, timeout isolation, cache
hits). They are budgets and invariants, not benchmarks. Do not weaken a gate to
make it pass — a red gate means the implementation is wrong.
SPEC.md holds the per-file implementation contracts and the full gate list.
MIT. Not affiliated with Google, Cloudflare or Vercel.