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. I build GSC Wizard https://www.gscwizard.com , 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.