Asking my website how it's doing: Google Analytics over MCP
A week after launching this site, I naturally want to know what's happening on it. But instead of opening Google Analytics, I went somewhere else today — I asked the agent in Claude Code:
what happened on the site over the last week?
And got a table back:
| Event | Count |
|---|---|
| page_view | 76 |
| blog_view | 7 |
| cta_click | 6 |
| blog_read_complete | 4 |
| contact_click | 1 |
(trimmed — the full output has 12 rows)
No dashboard, no clicking around. Behind the answer sits Google's official MCP server, which gives the agent access to Google Analytics — read-only, so it can't break anything in the data. I'll walk through why I bothered, how to set it up on a Mac and on Windows — and four things I actually pull from it.
(How I keep the project — this setup included — in sync across two computers is covered by One repo, two keyboards. This is about one machine and ten minutes.)
Why bother
GA4 hides its data thoroughly. If you've ever hunted for one specific number in it, you know the drill: the standard reports show everything except what you need right now, so you build an exploration, try to remember which tab holds custom definitions, and click.
But the main reason is elsewhere. The agent I work on the site with knows its context. It knows cta_click is the main button, that I send blog_read_complete when someone finishes an article, and that the site runs without cookies. So I don't have to speak the language of metrics — I can just ask how many people clicked the contact link this week, and leave the translation into dimensions and filters to it.
How I chose
There's more than one way to get GA data to an agent. I went through them, and the decision came quickly:
- Google's official MCP server — the only one with realtime reports and a custom definitions listing, and I wanted both. And Google maintains it.
- Community MCP servers — the most interesting one adds schema search across dimensions and metrics, but has no realtime and no custom definitions. And it's a one-man project; for analytics I'd rather bet on the official thing.
- CLI tools — practically nonexistent. I found a single abandoned repo on GitHub.
- Hosted services — paying monthly to route my numbers through a third party is not something I'm keen on for a personal site.
The second decision was authentication. Google's docs recommend signing in through gcloud — but that means installing an SDK of several hundred megabytes for a single login. A service account with a JSON key does the same job without gcloud, the key can be revoked per machine, and it never expires. For read-only access to a personal site's analytics, I'm fine with that.
Setup in five steps
The only prerequisite is uv — the server is a Python package that uvx downloads and runs by itself, nothing else to install:
brew install uv # Mac
winget install astral-sh.uv # WindowsBeyond that you need a Google Cloud project (free, no billing account) and about ten minutes:
- GCP project + two APIs. At console.cloud.google.com, create a project and enable the Google Analytics Admin API and the Google Analytics Data API.
- Service account + key. IAM → Service accounts → Create service account. It needs no GCP roles. Then on the account's detail: Keys → Add key → JSON.
- Viewer in GA. In GA4: Admin → Property access management → add the service account's e-mail with the Viewer role. That way it sees this one property and nothing else.
- Store the key outside the repo. On a Mac,
~/.config/google-analytics-mcp/sa-key.jsonpluschmod 600; on Windows, something likeC:\Users\<you>\.config\google-analytics-mcp\sa-key.json. Never into git — Google automatically invalidates keys found in public repos, and they don't belong in a private one either. - Register it in Claude Code. On a Mac:
claude mcp add analytics-mcp --scope user \
-e GOOGLE_APPLICATION_CREDENTIALS="$HOME/.config/google-analytics-mcp/sa-key.json" \
-e GOOGLE_PROJECT_ID=<your-gcp-project> \
-- uvx analytics-mcpOn Windows it's the same command, just with the key path in the C:\Users\<you>\... shape — the server receives it outside git-bash, so the /c/Users/... form means nothing to it.
One thing to watch: the tools only appear in a new session. Restart Claude Code after registering — claude mcp list reports connected right away, but you can't call the tools until after the restart.
And a note for anyone running multiple machines: the JSON key doesn't sync through the repo. Each machine gets its own key — which also means they can be revoked independently.
What I actually pull from it
The format of the examples is borrowed from the cheat sheet: the prompt I write, what runs under the hood, and what came back. Delegating doesn't mean losing track of what's going on.
What happened this week
The table from the intro. Under the hood it calls run_report with the eventName dimension, the eventCount metric, and a 7daysAgo..today range — and the agent comments on the output right away, because it knows which numbers are conversions.
The site is a week old and the numbers are small. But two of them interest me more than all the rest: 6 clicks on the main CTA, 1 on contact.
Localhost counts too. I found out during the very first real report: the dev server sends events to the same GA ID as production, and over a week of development that made up 68 % of all events — the numbers in this article included. The site no longer sends anything from localhost, and I filter hostName in queries to be safe. The first clean table will be the next one.
Which articles people finish
I count blog_view when an article opens and blog_read_complete when someone scrolls to 90 %. The ratio of the two = read-through rate — a more useful metric than bare pageviews, if you ask me.
which articles do people read, and how many finish them?
Under the hood: run_report with the customEvent:article_slug and eventName dimensions, filtered to those two events:
| article_slug | Event | Count |
|---|---|---|
| tahak-git-github-claude-code | blog_view | 6 |
| tahak-git-github-claude-code | blog_read_complete | 4 |
| ai-agenti-v-enterprise | blog_view | 1 |
This week the cheat sheet leads: 6 opens, 4 read-throughs. But the first time I ran that same query, the output looked different than I expected — most rows said (not set). Custom dimensions like article_slug only start filling in reports from the moment you register them in the GA admin — and I registered mine the day after launch. The launch traffic stays unattributed forever. Had I known earlier, I'd register the dimensions before the deploy, not after.
Does the config match? Did the deploy work?
The use case I built this whole thing for. The site sends 9 custom dimensions and 2 metrics, and every one of them has to be registered in the GA admin by hand — the parameter names have to match to the character.
list the custom dimensions and metrics registered in GA
and compare them against what the code actually sends
Under the hood, get_custom_dimensions_and_metrics; comparing against the code is the agent's homework, it has the code right in front of it. The result: 9 dimensions, 2 metrics, names matching 1:1. If they didn't match, I'd find out right here — not a month later from an empty report.
And after every deploy, a quick check:
I just opened the site — do you see my page_view in realtime?
Under the hood, run_realtime_report. Standard reports can lag a full day; realtime is the only way to verify a deploy right away.
Monthly scorecard
Once a month I have it put together an overview:
build me a monthly scorecard: contact_click, cta_click and blog_read_complete
by week, compared to last month
One thing worth explaining here. The site runs without cookies — no consent banner, but also no persistent visitor ID. Metrics like Users or Sessions don't mean anything on it, so I don't measure them. The scorecard stands on event counts: how many times someone clicked contact, how many times someone finished an article. For a personal site that's plenty — I don't need to know how many "users" came, I need to know whether anyone reached out.
Bonus: the same key opens Search Console
GA shows me what people do on the site. How they got to it through Google in the first place — which queries, what position — lives in Search Console. And that one has no official MCP server. No matter — the Search Console API is simple enough that a short script and the same service account cover it.
The steps build on the setup above:
- Enable the Search Console API in the same GCP project (a third one to join the two Analytics APIs).
- Add the service account as a user in Search Console. Settings → Users and permissions → the account's e-mail from step 2; the Restricted permission is enough.
- The script. About 90 lines of Python with zero dependencies: it signs a JWT via openssl (git-bash ships it on Windows), swaps it for an access token and calls the Search Analytics API. Mine lives in the repo as
scripts/gsc_report.py— I originally wrote it for another site and just switched the domain here.
The JWT signing is twenty lines of boilerplate — a short JSON gets signed with the private key, and the OAuth endpoint swaps it for an hour-long access token. The rest is short: the whole Search Analytics API is one endpoint, and the request body only says the date range and the dimensions.
def query(token: str, body: dict) -> list:
req = urllib.request.Request(
f"https://www.googleapis.com/webmasters/v3/sites/{urllib.parse.quote(SITE, safe='')}/searchAnalytics/query",
data=json.dumps(body).encode(),
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
return json.load(urllib.request.urlopen(req)).get("rows", [])
base = {"startDate": start, "endDate": end, "type": "web"}
totals = query(token, base) # summary
top_queries = query(token, {**base, "dimensions": ["query"], "rowLimit": 10})
top_pages = query(token, {**base, "dimensions": ["page"], "rowLimit": 5})No dimensions → the summary; query → top queries; page → top pages. That's all of it — no library, no SDK.
And the agent calls the script like any other tool:
how did the site do in search its first week?
Under the hood, python3 scripts/gsc_report.py 2026-07-12 2026-07-18:
"totals": { "clicks": 1, "impressions": 64, "ctr": 0.0156, "position": 8.2 }64 impressions, 1 click, average position 8. A site one week old — indexing is only getting going. Among the queries so far it's mostly the cheat sheet ("claude code cheat sheet github", position 7) and once even "mirek ai" at five. Small numbers, but exactly the ones I want to watch month by month — which is why the script stays in the repo, right next to the site.
Just watch one thing: the data runs about two days behind. That's why I end the window two days before today — newer rows would come back empty.
What it can't do
- Writes. The server is read-only. Registering dimensions, key events, data retention — the admin side of GA stays manual clicking. I treat that as a feature — the agent can't mess up the data.
- Fresh data in standard reports. GA processes them with a 24–48 hour delay. For "what's happening now" there's the realtime report; for everything else you wait a day.
- Perfect aim on names. A few times the agent made up a dimension name. The API returns an error and it corrects itself — worse would be silently getting wrong data. Which is why I checked the outputs against the GA UI at first, before I started trusting it.
Conclusion
Ten minutes of setup, and I have analytics I can question in plain language — analytics that knows my site. The numbers live where I work: in the terminal, right next to the code that sends them. I've opened the GA dashboard once since — and only for the admin clicking the server can't do.