Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 105 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Google Analytics MCP Server (Experimental)


[![PyPI version](https://img.shields.io/pypi/v/analytics-mcp.svg)](https://pypi.org/project/analytics-mcp/)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![GitHub branch check runs](https://img.shields.io/github/check-runs/googleanalytics/google-analytics-mcp/main)](https://github.com/googleanalytics/google-analytics-mcp/actions?query=branch%3Amain++)
Expand Down Expand Up @@ -66,88 +67,141 @@ to enable the following APIs in your Google Cloud project:

### Configure credentials 🔑

Configure your [Application Default Credentials
(ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc).
Make sure the credentials are for a user with access to your Google Analytics
accounts or properties.
This server supports two authentication methods:

Credentials must include the Google Analytics read-only scope:
#### Option 1: OAuth2 with Access/Refresh Tokens (Recommended for integrations)

```
https://www.googleapis.com/auth/analytics.readonly
```
This method is ideal for applications that need programmatic access without user interaction. You'll need to create a configuration file with your OAuth credentials and tokens.

Check out
[Manage OAuth Clients](https://support.google.com/cloud/answer/15549257)
for how to create an OAuth client.
Create a JSON configuration file with your OAuth credentials and tokens:

Here are some sample `gcloud` commands you might find useful:
```json
{
"googleOAuthCredentials": {
"clientId": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"clientSecret": "YOUR_CLIENT_SECRET",
"redirectUri": "http://localhost:3000/api/integration/google/callback"
},
"googleAnalyticsTokens": {
"accessToken": "YOUR_ACCESS_TOKEN",
"refreshToken": "YOUR_REFRESH_TOKEN",
"expiresAt": 1756420934
}
}
```

- Set up ADC using user credentials and an OAuth desktop or web client after
downloading the client JSON to `YOUR_CLIENT_JSON_FILE`.
To obtain OAuth credentials:

```shell
gcloud auth application-default login \
--scopes https://www.googleapis.com/auth/analytics.readonly,https://www.googleapis.com/auth/cloud-platform \
--client-id-file=YOUR_CLIENT_JSON_FILE
```
1. [Create OAuth credentials](https://support.google.com/cloud/answer/15549257) in the Google Cloud Console
2. Download the client configuration JSON file
3. Use the OAuth flow to obtain access and refresh tokens with the Google Analytics read-only scope:
```
https://www.googleapis.com/auth/analytics.readonly
```

- Set up ADC using service account impersonation.
#### Option 2: Application Default Credentials (ADC)

```shell
gcloud auth application-default login \
--impersonate-service-account=SERVICE_ACCOUNT_EMAIL \
--scopes=https://www.googleapis.com/auth/analytics.readonly,https://www.googleapis.com/auth/cloud-platform
```
This is the standard Google Cloud authentication method. If no OAuth config file is provided, the server will automatically use [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc).

When the `gcloud auth application-default` command completes, copy the
`PATH_TO_CREDENTIALS_JSON` file location printed to the console in the
following message. You'll need this for the next step!
To set up ADC:

```shell
gcloud auth application-default login \
--scopes https://www.googleapis.com/auth/analytics.readonly,https://www.googleapis.com/auth/cloud-platform \
--client-id-file=YOUR_CLIENT_JSON_FILE
```
Credentials saved to file: [PATH_TO_CREDENTIALS_JSON]
```

### Configure Gemini
### Configure Claude Desktop

1. Install Claude Desktop or use Claude Code.

2. Create or edit the Claude Desktop configuration file at `~/.config/claude/claude_desktop_config.json` (Linux/Mac) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).

3. Add the analytics-mcp server to the `mcpServers` list:

**With OAuth2 Config File:**
```json
{
"mcpServers": {
"analytics-mcp": {
"command": "python",
"args": [
"-m", "analytics_mcp.server",
"--config", "/path/to/your/google-analytics-config.json"
]
}
}
}
```

**With Application Default Credentials:**
```json
{
"mcpServers": {
"analytics-mcp": {
"command": "python",
"args": ["-m", "analytics_mcp.server"]
}
}
}
```

1. Install [Gemini
CLI](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/index.md)
or [Gemini Code
Assist](https://marketplace.visualstudio.com/items?itemName=Google.geminicodeassist).
### Configure Gemini (Alternative)

1. Create or edit the file at `~/.gemini/settings.json`, adding your server
to the `mcpServers` list.
For Gemini CLI users:

Replace `PATH_TO_CREDENTIALS_JSON` with the path you copied in the previous
step.
1. Install [Gemini CLI](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/index.md) or [Gemini Code Assist](https://marketplace.visualstudio.com/items?itemName=Google.geminicodeassist).

We also recommend that you add a `GOOGLE_CLOUD_PROJECT` attribute to the
`env` object. Replace `YOUR_PROJECT_ID` in the following example with the
[project ID](https://support.google.com/googleapi/answer/7014113) of your
Google Cloud project.
2. Create or edit the file at `~/.gemini/settings.json`:

**With OAuth2 Config File:**
```json
{
"mcpServers": {
"analytics-mcp": {
"command": "pipx",
"args": [
"run",
"analytics-mcp"
],
"env": {
"GOOGLE_APPLICATION_CREDENTIALS": "PATH_TO_CREDENTIALS_JSON",
"GOOGLE_PROJECT_ID": "YOUR_PROJECT_ID"
}
"analytics-mcp",
"--config", "/path/to/your/google-analytics-config.json"
]
}
}
}
```

**With Application Default Credentials:**
```json
{
"mcpServers": {
"analytics-mcp": {
"command": "pipx",
"args": ["run", "analytics-mcp"]
}
}
}
```

## Installation 📦

### Install from PyPI (Recommended)

```bash
pip install analytics-mcp
```

### Install from source

```bash
git clone https://github.com/googleanalytics/google-analytics-mcp.git
cd google-analytics-mcp
pip install -r requirements.txt
pip install -e .
```

## Try it out 🥼

Launch Gemini Code Assist or Gemini CLI and type `/mcp`. You should see
`analytics-mcp` listed in the results.
Launch Claude Desktop or Gemini and the server should automatically connect. For Claude Desktop, you can verify the connection in the MCP settings.

Here are some sample prompts to get you started:

Expand Down
187 changes: 187 additions & 0 deletions analytics_mcp/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Copyright 2025 Google LLC All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Authentication module for Google Analytics API.

Supports two authentication methods:
1. OAuth2 with access/refresh tokens from config file
2. Application Default Credentials (fallback)
"""

import json
import logging
from datetime import datetime, timezone
from typing import Optional

import google.auth
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials

logger = logging.getLogger(__name__)

# Read-only scope for Analytics Admin API and Analytics Data API
_READ_ONLY_ANALYTICS_SCOPE = "https://www.googleapis.com/auth/analytics.readonly"

# Global credentials cache
_cached_credentials: Optional[google.auth.credentials.Credentials] = None


def invalidate_cache():
"""Invalidate cached credentials to force refresh on next request."""
global _cached_credentials
logger.info("Invalidating cached credentials")
_cached_credentials = None


def create_credentials(config_path: Optional[str] = None, force_refresh: bool = False) -> google.auth.credentials.Credentials:
"""Create Google Analytics API credentials.

Tries OAuth2 from config file first, then falls back to Application Default Credentials.

Args:
config_path: Optional path to OAuth config file
force_refresh: If True, bypass cache and reload from disk

Returns:
Google auth credentials
"""
global _cached_credentials

# Return cached credentials if still valid and not forcing refresh
if not force_refresh and _cached_credentials and not _cached_credentials.expired:
logger.debug("Using cached credentials (not expired)")
return _cached_credentials
elif _cached_credentials and not force_refresh:
logger.info("Cached credentials expired, recreating")
elif force_refresh:
logger.info("Force refresh requested, reloading credentials from disk")

# Try OAuth2 authentication from config file
if config_path:
credentials = _try_oauth_authentication(config_path)
if credentials:
_cached_credentials = credentials
return credentials

# Fallback to Application Default Credentials
logger.info("Using Application Default Credentials")
credentials, _ = google.auth.default(scopes=[_READ_ONLY_ANALYTICS_SCOPE])
_cached_credentials = credentials
return credentials


def _try_oauth_authentication(config_path: str) -> Optional[Credentials]:
"""Try to authenticate using OAuth2 credentials from config file.

Args:
config_path: Path to config file with OAuth credentials

Returns:
Credentials object if successful, None otherwise
"""
logger.debug(f"Attempting OAuth authentication from: {config_path}")

try:
with open(config_path, 'r') as f:
config = json.load(f)

oauth_config = config.get('googleOAuthCredentials')
tokens = config.get('googleAnalyticsTokens')

# Check if we have OAuth configuration
if not oauth_config or not tokens:
logger.debug("No OAuth configuration found in config file")
return None

access_token = tokens.get('accessToken')
refresh_token = tokens.get('refreshToken')
client_id = oauth_config.get('clientId')
client_secret = oauth_config.get('clientSecret')

# Validate required fields
if not all([access_token, refresh_token, client_id, client_secret]):
logger.warning("OAuth config incomplete, missing required fields")
return None

# Convert expiresAt timestamp to datetime if available
expires_at = tokens.get('expiresAt')
expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc).replace(tzinfo=None) if expires_at else None

credentials = Credentials(
token=access_token,
refresh_token=refresh_token,
token_uri='https://oauth2.googleapis.com/token',
client_id=client_id,
client_secret=client_secret,
scopes=[_READ_ONLY_ANALYTICS_SCOPE],
expiry=expiry
)

# Always try to refresh if we have a refresh token
# This ensures we get a fresh token even if the cached one is stale
# The refresh is cheap and Google handles the actual expiry check
if refresh_token:
try:
# Check if token is expired or will expire soon (within 5 minutes)
should_refresh = not expires_at or credentials.expired
if expires_at and not credentials.expired:
# Preemptively refresh if token expires in < 5 minutes
time_until_expiry = expires_at - int(datetime.now(timezone.utc).timestamp())
should_refresh = time_until_expiry < 300 # 5 minutes

if should_refresh:
logger.info(f"Refreshing token (expired={credentials.expired})")
credentials.refresh(Request())
logger.info("Token refreshed successfully")

# Update the config file with new token
if credentials.token and credentials.expiry:
new_expires_at = int(credentials.expiry.timestamp())
_update_config_file(config_path, credentials.token, new_expires_at)
logger.info(f"Config file updated with new token (expires: {new_expires_at})")
else:
logger.debug(f"Using cached token (expires at {expires_at})")
except Exception as e:
logger.warning(f"Failed to refresh token, will retry on API call: {e}")
# Don't fail here - return the credentials and let retry_on_auth_error handle it

return credentials

except (FileNotFoundError, json.JSONDecodeError) as e:
logger.warning(f"Could not load OAuth config from file: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error during OAuth authentication: {e}", exc_info=True)
return None


def _update_config_file(config_path: str, new_access_token: str, expires_at: int):
"""Update the config file with new access token and expiry.

Args:
config_path: Path to config file
new_access_token: New access token
expires_at: Token expiration timestamp
"""
try:
with open(config_path, 'r') as f:
config = json.load(f)

config['googleAnalyticsTokens']['accessToken'] = new_access_token
config['googleAnalyticsTokens']['expiresAt'] = expires_at

with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
except Exception as e:
logger.warning(f"Failed to update config file: {e}")
Loading