cd /news/developer-tools/bringing-ga4-into-an-mcp-server-what… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-51236] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Bringing GA4 into an MCP server: what it actually takes to make analytics agent-friendly

A developer building GSC Wizard, an SEO analytics tool, created an MCP server that lets AI assistants query Google Analytics 4 data. The server includes six GA4 tools, notably one that tracks traffic from AI assistants like ChatGPT and Perplexity by matching session sources against a hand-maintained list of 18 assistants. The developer addressed GA4 API quirks such as recomputing engagement rates from raw counts to avoid averaging errors and transparently notes that Google's AI Overviews traffic is indistinguishable from organic search.

read7 min views1 publishedJul 8, 2026

I build GSC Wizard, an SEO analytics tool. A while back I added an MCP (Model Context Protocol) server so people could ask an AI assistant β€” ChatGPT, Claude, whatever β€” questions about their own search and analytics data and get real numbers back instead of hallucinations.

Wiring up Google Search Console was the easy part. Google Analytics 4 was where it got interesting. GA4's Data API has quirks that don't matter much when a human clicks a dashboard, but bite hard the moment an LLM is generating the requests. This post is about those quirks and how I dealt with them.

There are six GA4 tools in the server:

Tool What it answers
list_ga4_properties
Which GA4 properties can I read?
get_ga4_overview
Sessions, users, engagement β€” with period-over-period comparison
query_ga4_report
One breakdown (channel / source / page / country / device / event)
get_ga4_ecommerce
Revenue, transactions, top products
get_ga4_key_events
Conversions, segmented by any key event
get_ga4_llm_traffic
How much traffic is AI assistants sending you

I'll spend most of the post on the last one, because it's the most novel, then cover the plumbing that makes all six reliable.

This is the tool people react to. "How much of my traffic comes from ChatGPT, Perplexity, Gemini?" is a question every marketer suddenly has, and GA4 doesn't answer it out of the box β€” you have to know what to look for.

AI assistants show up in your GA4 sessionSource

dimension in two shapes:

chatgpt.com

, perplexity.ai

, gemini.google.com

.utm_source=chatgpt

or utm_source=openai

.So the source of truth is a hand-maintained list of assistants, each with its domains and its aliases:

// llm-sources.ts β€” one entry per assistant (18 of them)
{
  name: 'ChatGPT',
  domains: ['chatgpt.com', 'chat.openai.com'],
  aliases: ['chatgpt', 'openai'],
},
{
  name: 'Perplexity',
  domains: ['perplexity.ai', 'pplx.ai'],
  aliases: ['perplexity'],
},
// Copilot, Gemini, Claude, DeepSeek, Grok, Mistral, Meta AI,
// Poe, You.com, Phind, Kimi, Qwen, Genspark, Felo, iAsk, Andi...

That list drives two functions whose matching semantics have to stay byte-for-byte identical, or your filter and your labels disagree:

buildLLMSourceRegex()

builds an RE2-compatible pattern for the Data API's server-side FULL_REGEXP

filter. Domains match exactly or as any subdomain β€” (.*\.)?(chatgpt\.com|perplexity\.ai|...)

β€” and aliases match as exact values.classifyLLMSource(value)

runs client-side to map a returned sessionSource

back to a display name, so chat.openai.com

and chatgpt.com

both roll up into "ChatGPT."Push the filtering server-side and you only pay for the rows you want. Keep the classifier in lockstep and rows that slip through but don't classify land in an "Other LLM" bucket instead of vanishing.

Here's the part I made sure the tool description states outright: Google's AI Overviews and AI Mode carry no distinct referrer. That traffic is indistinguishable from plain google / organic

. So this tool measures assistants that send a click with a referrer β€” it is not, and cannot be, a total "AI influence" number. I'd rather the model tells the user that than quietly implies precision that doesn't exist.

One subtle bug I designed around: an assistant can appear under several sessionSource

values (chatgpt.com

and chat.openai.com

). If you fetch engagementRate

per row and then average, you get nonsense β€” you can't average two rates. So the tool fetches engagedSessions

and sessions

as raw counts and recomputes the rate after grouping:

engagementRate = sum(engagedSessions) / sum(sessions)

The tool also fetches an unfiltered sessions total in the same batch, purely as the denominator for "LLM traffic is X% of all sessions." That share-of-traffic number is the one people actually screenshot.

An LLM driving your API is a fundamentally different client from a dashboard. It sends malformed inputs, it fires requests in bursts, and it has no idea which of your three Google accounts owns the property. Four things had to be solid.

Models pass dates as null

, the string "null"

, empty strings, 2026/07/01

, or full ISO timestamps with a time component. If your schema rejects any of that, the tool just fails and the model apologizes to the user. So the date schema coerces junk to undefined

and defaults a missing range to the last 28 settled days (shifted back 3 days, because GA4 data lags):

// null, "null", "", slashes, time components β†’ undefined, then default window

Every GA4 tool shares it. The model can be sloppy and the call still works.

GA4 uses incremental consent, so typically only one of a user's connected Google accounts has the analytics.readonly

scope β€” and the property you want might be under any of them. The token layer fans out to every account that has the scope and rotates through them, but only on the errors that mean "wrong account":

// withGa4TokenRotation: rotate ONLY on GA4ApiError 401/403.
// Any other GA4 error is a real upstream failure β€” surface it, don't mask it.

Rotating on a 500 would just retry a broken request against every account and turn one failure into three. The distinction matters.

Underneath, tokens are stored encrypted in Postgres (Supabase). Access tokens are refreshed against Google's OAuth endpoint with a 5-minute expiry buffer, and concurrent refreshes for the same account are deduplicated with an in-flight map so a burst of tool calls doesn't fire N identical refresh requests.

The GA4 Data API's concurrent-request quota is per-property and shared β€” with your GA4 UI, your Looker Studio dashboards, and everything else hitting that property. An LLM that decides to fetch an overview, an ecommerce report, and a key-events report at once can blow the quota instantly.

Two guards:

withGA4PropertyLock(propertyId, fn)

Map

of chained promises), so two report fetches for the same property never overlap.batchRunReports

(which caps at 5 per call), with exponential backoff (1s β†’ 2s β†’ 4s) on 429s and 5xx.A single get_ga4_key_events

call might need six breakdowns β€” channels, sources, landing pages, pages, countries, devices. Batching turns that from six round-trips into two, serialized so it never trips the quota.

GA4 exposes per-event conversion metrics through a suffix syntax that's easy to miss: keyEvents:form_submit

, sessionKeyEventRate:form_submit

. The key-events tool runs in two phases β€” first discover which events are active (merging what's live in the data with what's configured via the Admin API), pick the most-active one as the default if the caller didn't name one, then segment every breakdown on that single event and rename the suffixed metrics back to stable keys before returning. That way the model gets keyEventCount

regardless of whether the event is purchase

, form_submit

, or newsletter_signup

.

The server speaks Streamable HTTP and gets used by ChatGPT (via the OpenAI Apps SDK) and by plain MCP clients like Claude. Those two want different things from the same tool result.

ChatGPT renders a widget β€” so for view-backed tools like get_ga4_overview

and get_ga4_llm_traffic

, the server advertises an HTML template resource and returns a compact text gist plus rich structuredContent

for the widget to draw. Every other client gets the full result serialized as JSON text, so nothing is lost for a model that's just going to read the numbers.

// Branch on the client: ChatGPT gets gist + structuredContent for the widget;
// everyone else gets the complete JSON so no data is dropped.
if (clientRendersWidgets()) { /* compact + structured */ }
else { /* full JSON */ }

There's a small gotcha buried in the filter schema, too. The shared GA4 filter shape (channel, source, country, device, event, page paths) deliberately builds a fresh schema instance per field instead of reusing a constant β€” because the JSON-Schema generator would otherwise dedupe them into $ref

s, and ChatGPT's Apps validator can't resolve $ref

. Sometimes "don't repeat yourself" loses to "the validator needs it spelled out."

The theme across all of this: an LLM is an adversarial client, not a malicious one, but adversarial in the fuzzing sense. It will send you inputs your dashboard code never produced, in orderings your UI never triggered.

The GA4 tools have been the most-used part of the server, and "how much traffic comes from AI" turned out to be the single question that made people install it. Fitting, given the tool answering it lives inside an AI assistant.

If you're building something similar and want to compare notes, I'm happy to reply here.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @gsc wizard 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/bringing-ga4-into-an…] indexed:0 read:7min 2026-07-08 Β· β€”