diff --git a/README.md b/README.md index 279da1d..2a39751 100644 --- a/README.md +++ b/README.md @@ -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++) @@ -66,66 +67,94 @@ 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": { @@ -133,21 +162,46 @@ Credentials saved to file: [PATH_TO_CREDENTIALS_JSON] "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: diff --git a/analytics_mcp/auth.py b/analytics_mcp/auth.py new file mode 100644 index 0000000..66fb457 --- /dev/null +++ b/analytics_mcp/auth.py @@ -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}") diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index ddb738e..9119f40 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -18,7 +18,43 @@ server using `@mcp.tool` annotations, thereby 'coordinating' the bootstrapping of the server. """ +import logging +from typing import Optional from mcp.server.fastmcp import FastMCP +logger = logging.getLogger(__name__) + +# Global variable to store config path +_config_path: Optional[str] = None + + +def set_config_path(config_path: str): + """Set the global config path for the MCP server. + + Args: + config_path: Path to OAuth config file + + Raises: + FileNotFoundError: If config file doesn't exist + """ + global _config_path + + # Validate file exists and is readable + with open(config_path, 'r') as f: + f.read(1) # Try to read at least 1 byte + + _config_path = config_path + logger.info(f"MCP server using config: {config_path}") + + +def get_config_path() -> Optional[str]: + """Get the global config path for the MCP server. + + Returns: + Config file path or None if not set + """ + return _config_path + + # Creates the singleton. mcp = FastMCP("Google Analytics Server") diff --git a/analytics_mcp/server.py b/analytics_mcp/server.py index 234f493..3375a68 100755 --- a/analytics_mcp/server.py +++ b/analytics_mcp/server.py @@ -16,7 +16,14 @@ """Entry point for the Google Analytics MCP server.""" -from analytics_mcp.coordinator import mcp +import sys +import os +import argparse + +# Add parent directory to path to make analytics_mcp importable +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from analytics_mcp.coordinator import mcp, set_config_path # The following imports are necessary to register the tools with the `mcp` # object, even though they are not directly used in this file. @@ -27,13 +34,39 @@ from analytics_mcp.tools.reporting import core # noqa: F401 -def run_server() -> None: +def run_server(config_path: str = None) -> None: """Runs the server. + Args: + config_path: Optional path to the Google Analytics OAuth configuration file. + If not provided, will use Application Default Credentials. + Serves as the entrypoint for the 'runmcp' command. """ + if config_path: + set_config_path(config_path) mcp.run() if __name__ == "__main__": - run_server() + parser = argparse.ArgumentParser(description="Google Analytics MCP Server") + parser.add_argument( + "--config", + type=str, + help="Path to Google Analytics OAuth configuration file (optional, uses ADC if not provided)", + default=os.environ.get('GOOGLE_ANALYTICS_CONFIG_PATH') + ) + + args = parser.parse_args() + + # Config is optional - if not provided, will use Application Default Credentials + if args.config: + print(f"Using OAuth config file: {args.config}", file=sys.stderr) + else: + print("No config file provided, will use Application Default Credentials", file=sys.stderr) + + try: + run_server(args.config) + except Exception as e: + print(f"Server failed to start: {e}", file=sys.stderr) + sys.exit(1) diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index a350d29..37cffdd 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -21,6 +21,7 @@ construct_property_rn, create_admin_api_client, proto_to_dict, + retry_on_auth_error, ) from google.analytics import admin_v1beta @@ -29,13 +30,16 @@ async def get_account_summaries() -> List[Dict[str, Any]]: """Retrieves information about the user's Google Analytics accounts and properties.""" - # Uses an async list comprehension so the pager returned by - # list_account_summaries retrieves all pages. - summary_pager = await create_admin_api_client().list_account_summaries() - all_pages = [ - proto_to_dict(summary_page) async for summary_page in summary_pager - ] - return all_pages + async def _get_summaries(): + # Uses an async list comprehension so the pager returned by + # list_account_summaries retrieves all pages. + summary_pager = await create_admin_api_client().list_account_summaries() + all_pages = [ + proto_to_dict(summary_page) async for summary_page in summary_pager + ] + return all_pages + + return await retry_on_auth_error(_get_summaries) @mcp.tool(title="List links to Google Ads accounts") @@ -47,16 +51,20 @@ async def list_google_ads_links(property_id: int | str) -> List[Dict[str, Any]]: - A number - A string consisting of 'properties/' followed by a number """ - request = admin_v1beta.ListGoogleAdsLinksRequest( - parent=construct_property_rn(property_id) - ) - # Uses an async list comprehension so the pager returned by - # list_google_ads_links retrieves all pages. - links_pager = await create_admin_api_client().list_google_ads_links( - request=request - ) - all_pages = [proto_to_dict(link_page) async for link_page in links_pager] - return all_pages + + async def _get_ads_links(): + request = admin_v1beta.ListGoogleAdsLinksRequest( + parent=construct_property_rn(property_id) + ) + # Uses an async list comprehension so the pager returned by + # list_google_ads_links retrieves all pages. + links_pager = await create_admin_api_client().list_google_ads_links( + request=request + ) + all_pages = [proto_to_dict(link_page) async for link_page in links_pager] + return all_pages + + return await retry_on_auth_error(_get_ads_links) @mcp.tool(title="Gets details about a property") @@ -67,9 +75,13 @@ async def get_property_details(property_id: int | str) -> Dict[str, Any]: - A number - A string consisting of 'properties/' followed by a number """ - client = create_admin_api_client() - request = admin_v1beta.GetPropertyRequest( - name=construct_property_rn(property_id) - ) - response = await client.get_property(request=request) - return proto_to_dict(response) + + async def _get_property(): + client = create_admin_api_client() + request = admin_v1beta.GetPropertyRequest( + name=construct_property_rn(property_id) + ) + response = await client.get_property(request=request) + return proto_to_dict(response) + + return await retry_on_auth_error(_get_property) diff --git a/analytics_mcp/tools/reporting/core.py b/analytics_mcp/tools/reporting/core.py index 881fb4f..00372a2 100644 --- a/analytics_mcp/tools/reporting/core.py +++ b/analytics_mcp/tools/reporting/core.py @@ -27,6 +27,7 @@ construct_property_rn, create_data_api_client, proto_to_dict, + retry_on_auth_error, ) from google.analytics import data_v1beta @@ -168,8 +169,10 @@ async def run_report( if currency_code: request.currency_code = currency_code - response = await create_data_api_client().run_report(request) + async def _execute_report(): + return await create_data_api_client().run_report(request) + response = await retry_on_auth_error(_execute_report) return proto_to_dict(response) diff --git a/analytics_mcp/tools/reporting/metadata.py b/analytics_mcp/tools/reporting/metadata.py index 1fa7a63..848d967 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -22,6 +22,7 @@ create_data_api_client, proto_to_dict, proto_to_json, + retry_on_auth_error, ) from google.analytics import data_v1beta @@ -329,9 +330,12 @@ async def get_custom_dimensions_and_metrics( - A string consisting of 'properties/' followed by a number """ - metadata = await create_data_api_client().get_metadata( - name=f"{construct_property_rn(property_id)}/metadata" - ) + async def _get_metadata(): + return await create_data_api_client().get_metadata( + name=f"{construct_property_rn(property_id)}/metadata" + ) + + metadata = await retry_on_auth_error(_get_metadata) custom_metrics = [ proto_to_dict(metric) for metric in metadata.metrics diff --git a/analytics_mcp/tools/reporting/realtime.py b/analytics_mcp/tools/reporting/realtime.py index 548882c..9623699 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -21,6 +21,7 @@ construct_property_rn, create_data_api_client, proto_to_dict, + retry_on_auth_error, ) from analytics_mcp.tools.reporting.metadata import ( get_date_ranges_hints, @@ -158,7 +159,10 @@ async def run_realtime_report( if offset: request.offset = offset - response = await create_data_api_client().run_realtime_report(request) + async def _execute_realtime_report(): + return await create_data_api_client().run_realtime_report(request) + + response = await retry_on_auth_error(_execute_realtime_report) return proto_to_dict(response) diff --git a/analytics_mcp/tools/utils.py b/analytics_mcp/tools/utils.py index 34eec86..002c80a 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -14,14 +14,23 @@ """Common utilities used by the MCP server.""" -from typing import Any, Dict +from typing import Any, Dict, Callable, TypeVar, Awaitable +import logging +import sys from google.analytics import admin_v1beta, data_v1beta from google.api_core.gapic_v1.client_info import ClientInfo +from google.api_core.exceptions import Unauthenticated, Forbidden from importlib import metadata -import google.auth import proto +from ..auth import create_credentials, invalidate_cache as invalidate_auth_cache + +T = TypeVar('T') + +# Configure logger +logger = logging.getLogger(__name__) + def _get_package_version_with_fallback(): """Returns the version of the package. @@ -39,36 +48,145 @@ def _get_package_version_with_fallback(): user_agent=f"analytics-mcp/{_get_package_version_with_fallback()}" ) -# Read-only scope for Analytics Admin API and Analytics Data API. -_READ_ONLY_ANALYTICS_SCOPE = ( - "https://www.googleapis.com/auth/analytics.readonly" -) +# Global client cache +_cached_admin_client = None +_cached_data_client = None + + +def invalidate_cached_credentials(): + """Invalidate cached credentials and clients to force refresh on next request.""" + global _cached_admin_client, _cached_data_client + logger.info("Invalidating cached API clients and credentials") + invalidate_auth_cache() + _cached_admin_client = None + _cached_data_client = None + +async def retry_on_auth_error(func: Callable[[], Awaitable[T]], max_retries: int = 1) -> T: + """Retry a function call if it fails with authentication errors. -def _create_credentials() -> google.auth.credentials.Credentials: - """Returns Application Default Credentials with read-only scope.""" - (credentials, _) = google.auth.default(scopes=[_READ_ONLY_ANALYTICS_SCOPE]) - return credentials + This handles cases where the cached credentials are expired by invalidating + the cache and retrying once with fresh credentials. + + Args: + func: Async function to call + max_retries: Maximum number of retries (default: 1) + + Returns: + The result of the function call + + Raises: + The original exception if all retries are exhausted + """ + last_exception = None + + for attempt in range(max_retries + 1): + try: + return await func() + except (Unauthenticated, Forbidden) as e: + last_exception = e + error_msg = str(e).lower() + logger.warning(f"Auth error caught: {error_msg[:200]}") + + # Check if it's an authentication/authorization error + if any(keyword in error_msg for keyword in [ + '401', 'unauthorized', 'unauthenticated', + 'invalid authentication', 'authentication credential', + 'access token', 'expired', 'refresh' + ]): + if attempt < max_retries: + logger.info(f"Authentication error detected, refreshing credentials and retrying (attempt {attempt + 1}/{max_retries + 1})") + invalidate_cached_credentials() + continue + else: + logger.error(f"Authentication failed after {max_retries + 1} attempts: {e}") + print("💡 Try running: python refresh_and_update_config.py ", file=sys.stderr) + + # Re-raise if it's not an auth error or we've exhausted retries + raise + except Exception as e: + # For non-auth errors, don't retry + logger.debug(f"Non-auth error (not retrying): {type(e).__name__}: {str(e)[:100]}") + raise + + # This shouldn't be reached, but just in case + if last_exception: + raise last_exception + + +def _get_config_path() -> str: + """Get the config file path from coordinator.""" + # Import here to avoid circular imports + from ..coordinator import get_config_path + + try: + return get_config_path() + except RuntimeError: + return None def create_admin_api_client() -> admin_v1beta.AnalyticsAdminServiceAsyncClient: """Returns a properly configured Google Analytics Admin API async client. - Uses Application Default Credentials with read-only scope. + Automatically handles credential refresh and caching. """ - return admin_v1beta.AnalyticsAdminServiceAsyncClient( - client_info=_CLIENT_INFO, credentials=_create_credentials() - ) + global _cached_admin_client + + # Get config path from coordinator + config_path = _get_config_path() + + # If cache was invalidated, recreate client with fresh credentials + if _cached_admin_client is None: + logger.debug("Creating new Admin API client (cache was cleared)") + creds = create_credentials(config_path) + _cached_admin_client = admin_v1beta.AnalyticsAdminServiceAsyncClient( + client_info=_CLIENT_INFO, credentials=creds + ) + return _cached_admin_client + + # Check if cached client's credentials are still valid + creds = create_credentials(config_path) + if creds.expired: + logger.debug("Recreating Admin API client (credentials expired)") + _cached_admin_client = admin_v1beta.AnalyticsAdminServiceAsyncClient( + client_info=_CLIENT_INFO, credentials=creds + ) + else: + logger.debug("Reusing cached Admin API client") + + return _cached_admin_client def create_data_api_client() -> data_v1beta.BetaAnalyticsDataAsyncClient: """Returns a properly configured Google Analytics Data API async client. - Uses Application Default Credentials with read-only scope. + Automatically handles credential refresh and caching. """ - return data_v1beta.BetaAnalyticsDataAsyncClient( - client_info=_CLIENT_INFO, credentials=_create_credentials() - ) + global _cached_data_client + + # Get config path from coordinator + config_path = _get_config_path() + + # If cache was invalidated, recreate client with fresh credentials + if _cached_data_client is None: + logger.debug("Creating new Data API client (cache was cleared)") + creds = create_credentials(config_path) + _cached_data_client = data_v1beta.BetaAnalyticsDataAsyncClient( + client_info=_CLIENT_INFO, credentials=creds + ) + return _cached_data_client + + # Check if cached client's credentials are still valid + creds = create_credentials(config_path) + if creds.expired: + logger.debug("Recreating Data API client (credentials expired)") + _cached_data_client = data_v1beta.BetaAnalyticsDataAsyncClient( + client_info=_CLIENT_INFO, credentials=creds + ) + else: + logger.debug("Reusing cached Data API client") + + return _cached_data_client def construct_property_rn(property_value: int | str) -> str: diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..6e70a21 --- /dev/null +++ b/config.example.json @@ -0,0 +1,12 @@ +{ + "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 + } +} \ No newline at end of file diff --git a/refresh_and_update_config.py b/refresh_and_update_config.py new file mode 100644 index 0000000..1117d0f --- /dev/null +++ b/refresh_and_update_config.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Script to manually refresh Google OAuth token and update config with expiry. + +This script is useful if you need to manually refresh your OAuth token, +though the MCP server will automatically refresh tokens as needed. + +Usage: + python refresh_and_update_config.py /path/to/config.json +""" + +import json +import sys +from google.oauth2.credentials import Credentials +from google.auth.transport.requests import Request + + +def refresh_and_update_config(config_path: str) -> bool: + """Refresh the Google OAuth access token and update config file. + + Args: + config_path: Path to the Google Analytics config JSON file + + Returns: + True if successful, False otherwise + """ + try: + # Load current config + with open(config_path, 'r') as f: + config = json.load(f) + + oauth_config = config.get('googleOAuthCredentials', {}) + tokens = config.get('googleAnalyticsTokens', {}) + + if not oauth_config or not tokens: + print("❌ Config file missing OAuth credentials or tokens", file=sys.stderr) + return False + + # Create credentials + credentials = Credentials( + token=tokens.get('accessToken'), + refresh_token=tokens.get('refreshToken'), + token_uri='https://oauth2.googleapis.com/token', + client_id=oauth_config.get('clientId'), + client_secret=oauth_config.get('clientSecret'), + scopes=['https://www.googleapis.com/auth/analytics.readonly'] + ) + + # Refresh the token + credentials.refresh(Request()) + + # Update config with new token and expiry + config['googleAnalyticsTokens']['accessToken'] = credentials.token + if credentials.expiry: + config['googleAnalyticsTokens']['expiresAt'] = int(credentials.expiry.timestamp()) + + # Save updated config + with open(config_path, 'w') as f: + json.dump(config, f, indent=2) + + print(f"✅ Token refreshed and saved to {config_path}") + return True + + except FileNotFoundError: + print(f"❌ Config file not found: {config_path}", file=sys.stderr) + return False + except Exception as e: + print(f"❌ Error refreshing token: {e}", file=sys.stderr) + return False + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python refresh_and_update_config.py ", file=sys.stderr) + print("\nExample:", file=sys.stderr) + print(" python refresh_and_update_config.py /path/to/google-analytics-config.json", file=sys.stderr) + sys.exit(1) + + config_path = sys.argv[1] + success = refresh_and_update_config(config_path) + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3cf74c3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +google-analytics-data==0.18.19 +google-analytics-admin==0.24.1 +google-auth~=2.40 +mcp[cli]>=1.2.0 +httpx>=0.28.1 \ No newline at end of file