Google Analytics MCP Server
by minholi
README.md
# Google Analytics MCP Server
[MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that integrates with the **Google Analytics 4 Data API**. Lets AI agents (Claude, Cursor, etc.) query traffic, acquisition sources, top pages, audience breakdowns, geographic performance, events, conversions, and realtime data — all through natural language.
---
## Two operating modes
| Mode | Who uses it | Auth | Google refresh token |
|---|---|---|---|
| **stdio** | Just you (Claude Desktop / Cursor local) | None — local subprocess | Single, in `.env` (generated by `scripts/get_refresh_token.py`) |
| **HTTP multi-tenant** | Team / remote access | Native MCP OAuth 2.1 — each user authenticates with their own Google account | One per user, encrypted on disk |
Pick **stdio** for personal use (simpler). Pick **HTTP** when multiple users (with different Google accounts) need to share the same server.
---
## Available tools
| Tool | Description |
|---|---|
| `google_analytics_get_overview` | Main KPIs (sessions, users, pageviews, bounce, engagement, conversions), optionally compared to the previous period |
| `google_analytics_get_traffic_sources` | Acquisition grouped by channel, source/medium or campaign |
| `google_analytics_get_top_pages` | Most-visited pages with engagement metrics; optional path-prefix filter |
| `google_analytics_get_geographic_performance` | Sessions/conversions by country, region or city, with highlights |
| `google_analytics_get_audience` | Audience breakdown by device, browser or operating system |
| `google_analytics_get_events` | Top events by count; optional filter to specific event names |
| `google_analytics_get_conversions` | Conversion events with revenue, per channel and event |
| `google_analytics_get_realtime` | Users active right now, by page, source, country and device |
All accept: flexible date ranges (`last_7_days`, `last_14_days`, `last_30_days`, `this_month`, `last_month`, custom `YYYY-MM-DD`), output format (`markdown` or `json`), and optional `property_id`.
---
## Setup A — stdio mode (single-tenant)
### 1. Prerequisites
- Python 3.12+
- A GA4 property you have access to
- Project in [Google Cloud Console](https://console.cloud.google.com/) with the **Google Analytics Data API** enabled
### 2. Installation
```bash
git clone https://github.com/minholi/google-analytics-mcp.git
cd google-analytics-mcp
uv sync
```
### 3. "Desktop" OAuth Client in GCP
1. `APIs & Services → Credentials → Create Credentials → OAuth client ID`
2. Application type: **Desktop app**
3. Save the `Client ID` and `Client secret`
### 4. Refresh token
Interactive wizard:
```bash
uv run python scripts/get_refresh_token.py
```
Paste the `Client ID`/`Client secret` when prompted; it opens the browser, you log in, and copy the resulting `refresh_token`.
### 5. `.env`
```env
GOOGLE_ANALYTICS_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_ANALYTICS_CLIENT_SECRET=...
GOOGLE_ANALYTICS_REFRESH_TOKEN=...
GOOGLE_ANALYTICS_PROPERTY_ID=123456789
```
Find the numeric property ID in **Analytics Admin → Property details**.
### 6. Client configuration
**Claude Desktop** — `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"google-analytics": {
"command": "uv",
"args": ["run", "python", "main.py", "--transport", "stdio"],
"cwd": "/path/to/google-analytics-mcp"
}
}
}
```
**Cursor** — `.cursor/mcp.json`:
```json
{
"mcpServers": {
"google-analytics": {
"command": "uv",
"args": ["run", "python", "main.py", "--transport", "stdio"],
"cwd": "/path/to/google-analytics-mcp"
}
}
}
```
`uv run` loads the `.env` automatically via `python-dotenv`.
---
## Setup B — HTTP multi-tenant mode
### How it works
```
Claude Desktop ──(MCP OAuth: tokens issued by us)──▶ our MCP (AS+RS)
│
└──(Google OAuth: user's refresh_token)──▶ GA4 Data API
```
There are **two chained OAuth flows**: the server is simultaneously an Authorization Server (issues its own JWTs to Claude Desktop) and a Google OAuth Client (holds the encrypted refresh_token for the user's Google account). Desktop **never** sees the Google refresh_token.
UX in Claude Desktop:
1. User adds the MCP URL under Settings → Connectors (once).
2. Clicks "Connect" → browser opens → Google login → `analytics.readonly` consent → returns connected.
3. From then on, all calls are authenticated. Refresh is silent.
### 1. Prerequisites
- Public domain with TLS (e.g., `mcp.your-domain.com`)
- Caddy (or Nginx) **running on the host** — will terminate TLS and reverse-proxy to `127.0.0.1:8000`
- Docker + Docker Compose
### 2. "Web application" OAuth Client in GCP
> ⚠️ **Separate** client from the Desktop one used in Setup A.
1. Enable the Google Analytics Data API if you haven't already.
2. Configure the **OAuth consent screen**:
- User type: **External**
- Add the scope `https://www.googleapis.com/auth/analytics.readonly`
- Under "Test users" add the emails that will test (or click **Publish**).
3. **Create Credentials → OAuth client ID**:
- Application type: **Web application**
- **Authorized redirect URIs**: `https://mcp.your-domain.com/auth/callback` (must match the `MCP_PUBLIC_URL` you configure **exactly**)
4. Save the `Client ID` and `Client secret`.
### 3. (Optional) Generate a JWT signing key
```bash
# Only if you want to pin the key (e.g., multiple replicas).
# Without this, GoogleProvider derives the key from the client_secret.
uv run python -c "import secrets; print(secrets.token_urlsafe(48))"
```
### 4. `.env`
```env
# Web OAuth Client (step 2)
GOOGLE_OAUTH_WEB_CLIENT_ID=123456789-abc.apps.googleusercontent.com
GOOGLE_OAUTH_WEB_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxx
# Public URL — no trailing slash, with https
MCP_PUBLIC_URL=https://mcp.your-domain.com
# Optional: pinned key for signing MCP JWTs (step 3)
# OAUTH_JWT_SIGNING_KEY=Z9K1u7c-paste-the-output-of-secrets-token-urlsafe-48
# Server
MCP_TRANSPORT=http
MCP_HOST=0.0.0.0
MCP_PORT=8000
```
Setup A variables (`GOOGLE_ANALYTICS_CLIENT_ID`, `GOOGLE_ANALYTICS_CLIENT_SECRET`, `GOOGLE_ANALYTICS_REFRESH_TOKEN`) are **not used** in HTTP mode — you can remove or comment them out. `GOOGLE_ANALYTICS_PROPERTY_ID` is still handy as a default that individual users can override per tool call.
### 5. Host Caddy
```caddyfile
mcp.your-domain.com {
reverse_proxy 127.0.0.1:8000
}
```
### 6. Start
```bash
mkdir -p data
docker compose up -d --build
docker compose logs -f google-analytics-mcp
```
### 7. Verification
```bash
curl https://mcp.your-domain.com/.well-known/oauth-authorization-server
```
Should return JSON with `issuer`, `authorization_endpoint`, etc.
### 8. Connect from Claude Desktop
Settings → Connectors → Add custom connector → paste `https://mcp.your-domain.com/mcp` → click **Connect** → Google flow opens → log in → property authorized.
Done. Other users can do the same on their machines.
---
## Environment variables
### Common (both modes)
| Variable | Description |
|---|---|
| `GOOGLE_ANALYTICS_PROPERTY_ID` | Default numeric GA4 property ID (optional; can be passed per call) |
| `MCP_TRANSPORT` | `stdio` (default) or `http` |
| `MCP_PORT` | HTTP port (default `8000`) |
| `MCP_HOST` | HTTP host (default `0.0.0.0`) |
### stdio mode
| Variable | Required | Description |
|---|---|---|
| `GOOGLE_ANALYTICS_CLIENT_ID` | ✅ | Desktop OAuth Client ID |
| `GOOGLE_ANALYTICS_CLIENT_SECRET` | ✅ | Desktop OAuth Client Secret |
| `GOOGLE_ANALYTICS_REFRESH_TOKEN` | ✅ | Refresh token generated by `scripts/get_refresh_token.py` |
### HTTP multi-tenant mode
| Variable | Required | Description |
|---|---|---|
| `MCP_PUBLIC_URL` | ✅ | Public URL, no trailing `/`. Must match the redirect URI in GCP |
| `GOOGLE_OAUTH_WEB_CLIENT_ID` | ✅ | Web OAuth Client ID |
| `GOOGLE_OAUTH_WEB_CLIENT_SECRET` | ✅ | Web OAuth Client Secret |
| `OAUTH_JWT_SIGNING_KEY` | — | Optional. If absent, `GoogleProvider` derives the key from the client_secret. Set only for multiple replicas |
---
## Architecture
```
google-analytics-mcp/
├── main.py # Entry point — stdio or HTTP (mcp.http_app)
├── src/
│ ├── server.py # 8 MCP tools (fastmcp) + conditional GoogleProvider
│ ├── client.py # GA4 Data API v1beta client (httpx + REST)
│ ├── auth.py # GoogleAnalyticsAuth — env vars (stdio) or injected access_token (HTTP)
│ └── formatters.py # Markdown / JSON
├── scripts/
│ └── get_refresh_token.py # OAuth wizard (stdio mode)
├── Dockerfile
├── docker-compose.yml # Exposes 127.0.0.1:8000 (host Caddy handles TLS)
├── pyproject.toml
└── .env.example
```
**Data flow (HTTP mode):** `tool call → JWT verified by GoogleProvider (token-swap JTI → decrypted upstream Google access_token) → get_access_token().token → GoogleAnalyticsAuth.for_access_token → GA4 Data API REST v1beta → formatter`. The Google refresh_token stays encrypted in `GoogleProvider`'s internal key-value store; refresh is transparent when the access_token expires.
---
## Usage examples (natural language)
```
"Show the overview for the last 7 days, comparing with the previous week"
"Which channels are driving the most sessions in the last 30 days?"
"Which pages have the highest bounce rate this month?"
"Where are my users coming from geographically?"
"How is the audience split between mobile and desktop?"
"Which events are firing most in the last 14 days?"
"How many conversions did I get last month, and where did the revenue come from?"
"How many users are on the site right now?"
```
See [USAGE_GUIDE.md](USAGE_GUIDE.md) for scenario-driven playbooks and a full tool reference.
---
## Operations
### Logs
```bash
docker compose logs -f google-analytics-mcp
```
### Backup
- `GoogleProvider` persists DCR clients and encrypted Google refresh_tokens in `./data/` (mounted from the host). Back this directory up if user reconnection would be disruptive.
- `OAUTH_JWT_SIGNING_KEY` — if you change it (or if you're using the default derivation and change `GOOGLE_OAUTH_WEB_CLIENT_SECRET`), all users must reconnect.
### Rebuild after code changes
```bash
docker compose up -d --build
```
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `redirect_uri_mismatch` in Google consent | URI in GCP ≠ `${MCP_PUBLIC_URL}/auth/callback` | Check character by character (https, no trailing slash in `MCP_PUBLIC_URL`) |
| "Google did not return a refresh_token" in the callback | User previously authorized the app (Google only returns `refresh_token` on the first consent) | Ask them to revoke at [myaccount.google.com/permissions](https://myaccount.google.com/permissions) and reconnect |
| `401 invalid_token` on `/mcp` | MCP JWT expired or `OAUTH_JWT_SIGNING_KEY` changed | Desktop refreshes on its own; if it persists, reconnect from Connectors |
| Tool returns `PERMISSION_DENIED` from the GA4 API | User doesn't have access to the requested property, or the property_id is wrong | Confirm the account has at least Viewer access in GA4 |
| Docker healthcheck failing | Missing `MCP_PUBLIC_URL` or some `OAUTH_*` in `.env` | `docker compose logs google-analytics-mcp` shows which variable is missing |
| `OPENID_DISCOVERY_FAILED` in Claude Desktop | Broken DNS/TLS or Caddy not routing | `curl -v https://mcp.../.well-known/oauth-authorization-server` |
---
## Security
- **Credentials never in code** — always in `.env` (already in `.gitignore`).
- **Host Caddy** — terminates TLS. The container only listens on `127.0.0.1:8000`, not reachable directly from the internet.
- **Rate limiting** — configure on the host Caddy, not in the app.
- **Container runs as a non-root user.**
- **Google refresh_tokens encrypted at rest** by `GoogleProvider`'s internal key-value store (persisted in `./data/`).
- **Short-lived MCP JWTs** with transparent upstream Google refresh and MCP refresh token rotation (OAuth 2.1) — all managed by `GoogleProvider`.
- Treat `OAUTH_JWT_SIGNING_KEY` (if set) and `GOOGLE_OAUTH_WEB_CLIENT_SECRET` as critical secrets (vault/secret manager in production).
---
## License
MIT — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues