From 34a8575aad3ab94019c815cb0fa9f75bfd464dec Mon Sep 17 00:00:00 2001 From: Menelaos Kotsollaris Date: Thu, 28 Aug 2025 13:49:06 -0400 Subject: [PATCH 1/8] make repo token-based instead of Auth2 --- README.md | 147 ++++++++++++++++++++++------------- analytics_mcp/coordinator.py | 19 +++++ analytics_mcp/server.py | 36 ++++++++- analytics_mcp/tools/utils.py | 134 ++++++++++++++++++++++++++++++- refresh_token.py | 60 ++++++++++++++ requirements.txt | 5 ++ run_mcp_server.py | 33 ++++++++ run_server.sh | 19 +++++ test_oauth.py | 39 ++++++++++ 9 files changed, 434 insertions(+), 58 deletions(-) create mode 100644 refresh_token.py create mode 100644 requirements.txt create mode 100755 run_mcp_server.py create mode 100755 run_server.sh create mode 100644 test_oauth.py diff --git a/README.md b/README.md index 279da1d..8b6371f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Google Analytics MCP Server (Experimental) +> **Note**: This is a fork modified to work with OAuth2 Access Tokens + Refresh Tokens instead of Application Default Credentials for easier integration and token management. + [![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,65 +68,93 @@ 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 uses OAuth2 credentials with access and refresh tokens instead of Application Default Credentials (ADC). You'll need to create a configuration file with your OAuth credentials and tokens. + +#### Option 1: Using OAuth2 Config File (Recommended) + +Create a JSON configuration file with your OAuth credentials and tokens: + +```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 + } +} +``` -Credentials must include the Google Analytics read-only scope: +To obtain OAuth credentials: -``` -https://www.googleapis.com/auth/analytics.readonly -``` +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 + ``` -Check out -[Manage OAuth Clients](https://support.google.com/cloud/answer/15549257) -for how to create an OAuth client. +#### Option 2: Fallback to Application Default Credentials -Here are some sample `gcloud` commands you might find useful: +If no config file is provided, the server will fallback to [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc). -- Set up ADC using user credentials and an OAuth desktop or web client after - downloading the client JSON to `YOUR_CLIENT_JSON_FILE`. +```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 +``` - ```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 - ``` +### Configure Claude Desktop -- Set up ADC using service account impersonation. +1. Install Claude Desktop or use Claude Code. - ```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 - ``` +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). -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! +3. Add the analytics-mcp server to the `mcpServers` list: -``` -Credentials saved to file: [PATH_TO_CREDENTIALS_JSON] -``` + **For OAuth2 Config File (Recommended):** + ```json + { + "mcpServers": { + "analytics-mcp": { + "command": "python", + "args": [ + "-m", "analytics_mcp.server", + "/path/to/your/google-analytics-config.json" + ] + } + } + } + ``` -### Configure Gemini + **For Direct Python Execution:** + ```json + { + "mcpServers": { + "analytics-mcp": { + "command": "/path/to/python", + "args": [ + "/path/to/analytics-mcp/run_mcp_server.py", + "/path/to/your/google-analytics-config.json" + ] + } + } + } + ``` -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). + Replace `/path/to/your/google-analytics-config.json` with the full path to your OAuth configuration file. -1. Create or edit the file at `~/.gemini/settings.json`, adding your server - to the `mcpServers` list. +### Configure Gemini (Alternative) - Replace `PATH_TO_CREDENTIALS_JSON` with the path you copied in the previous - step. +For Gemini CLI users: - 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. +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). + +2. Create or edit the file at `~/.gemini/settings.json`: ```json { @@ -133,21 +163,34 @@ 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", + "/path/to/your/google-analytics-config.json" + ] } } } ``` +## 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/coordinator.py b/analytics_mcp/coordinator.py index ddb738e..62af343 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -18,7 +18,26 @@ server using `@mcp.tool` annotations, thereby 'coordinating' the bootstrapping of the server. """ +import os +import sys from mcp.server.fastmcp import FastMCP +# Global variable to store config path +_config_path = None + +def set_config_path(config_path: str): + """Set the global config path for the MCP server.""" + global _config_path + if not os.path.exists(config_path): + raise FileNotFoundError(f"Config file not found: {config_path}") + _config_path = config_path + print(f"MCP server using config: {config_path}", file=sys.stderr) + +def get_config_path() -> str: + """Get the global config path for the MCP server.""" + if _config_path is None: + raise RuntimeError("Config path not set. Call set_config_path() first.") + 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..763819e 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,36 @@ 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: Path to the Google Analytics configuration file. + 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 configuration file", + default=os.environ.get('GOOGLE_ANALYTICS_CONFIG_PATH') + ) + + args = parser.parse_args() + + if not args.config: + print("Error: Config file path required. Use --config or set GOOGLE_ANALYTICS_CONFIG_PATH", file=sys.stderr) + sys.exit(1) + + 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/utils.py b/analytics_mcp/tools/utils.py index 34eec86..30cf6fa 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -15,9 +15,15 @@ """Common utilities used by the MCP server.""" from typing import Any, Dict +import json +import os +import sys +import time from google.analytics import admin_v1beta, data_v1beta from google.api_core.gapic_v1.client_info import ClientInfo +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials from importlib import metadata import google.auth import proto @@ -44,11 +50,133 @@ def _get_package_version_with_fallback(): "https://www.googleapis.com/auth/analytics.readonly" ) +# Global credentials cache to avoid recreating credentials +_cached_credentials = 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.""" + try: + print(f"Attempting to update config file: {config_path}", file=sys.stderr) + + with open(config_path, 'r') as f: + config = json.load(f) + + # Store old token for debugging + old_token = config['googleAnalyticsTokens']['accessToken'][:50] + "..." + + config['googleAnalyticsTokens']['accessToken'] = new_access_token + config['googleAnalyticsTokens']['expiresAt'] = expires_at + + with open(config_path, 'w') as f: + json.dump(config, f, indent=2) + + print(f"āœ… Successfully updated config file", file=sys.stderr) + print(f" Old token: {old_token}", file=sys.stderr) + print(f" New token: {new_access_token[:50]}...", file=sys.stderr) + print(f" Expires at: {expires_at}", file=sys.stderr) + + except Exception as e: + print(f"āŒ Failed to update config file: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + 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 + """Returns OAuth2 credentials from config file with read-only scope and auto-refresh.""" + global _cached_credentials + + print(f"šŸ”„ _create_credentials called", file=sys.stderr) + + # FORCE REFRESH - don't use cached credentials for debugging + # if _cached_credentials and not _cached_credentials.expired: + # return _cached_credentials + + # Import here to avoid circular imports + from ..coordinator import get_config_path + + try: + config_path = get_config_path() + print(f"šŸ”„ Using config path from coordinator: {config_path}", file=sys.stderr) + except RuntimeError as e: + print(f"šŸ”„ Coordinator not initialized: {e}", file=sys.stderr) + # Fallback to environment variable if coordinator not initialized + config_path = os.environ.get('GOOGLE_ANALYTICS_CONFIG_PATH') + print(f"šŸ”„ Using env config path: {config_path}", file=sys.stderr) + if not config_path: + raise RuntimeError("No config path provided. Set GOOGLE_ANALYTICS_CONFIG_PATH environment variable.") + + print(f"šŸ”„ Loading config from: {config_path}", file=sys.stderr) + + try: + with open(config_path, 'r') as f: + config = json.load(f) + + print(f"šŸ”„ Config loaded successfully", file=sys.stderr) + + oauth_config = config.get('googleOAuthCredentials', {}) + tokens = config.get('googleAnalyticsTokens', {}) + + # Check if token is expired before creating credentials + expires_at = tokens.get('expiresAt') + current_time = int(time.time()) + + print(f"šŸ”„ Token expires at: {expires_at}, current time: {current_time}", file=sys.stderr) + + # If we have expiry info and token is not expired, use current token + if expires_at and current_time < expires_at: + print(f"šŸ”„ Using existing valid access token", file=sys.stderr) + else: + print(f"šŸ”„ Access token expired or no expiry info, will refresh", file=sys.stderr) + + access_token = tokens.get('accessToken') + refresh_token = tokens.get('refreshToken') + client_id = oauth_config.get('clientId') + client_secret = oauth_config.get('clientSecret') + + print(f"šŸ”„ Creating credentials with:", file=sys.stderr) + print(f" Access token: {access_token[:50] if access_token else 'NONE'}...", file=sys.stderr) + print(f" Refresh token: {refresh_token[:50] if refresh_token else 'NONE'}...", file=sys.stderr) + print(f" Client ID: {client_id}", file=sys.stderr) + + 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] + ) + + print(f"šŸ”„ Credentials created. Expired: {credentials.expired}, Valid: {credentials.valid}", file=sys.stderr) + + # FORCE REFRESH - Always refresh if no expiry info or if expired + if (not expires_at or credentials.expired) and credentials.refresh_token: + print(f"šŸ”„ Forcing token refresh (no expiry info or expired)...", file=sys.stderr) + try: + credentials.refresh(Request()) + print(f"šŸ”„ Token refresh SUCCESS! New token: {credentials.token[:50]}...", file=sys.stderr) + + # Update the config file with new token + if credentials.token and credentials.expiry: + expires_at = int(credentials.expiry.timestamp()) + _update_config_file(config_path, credentials.token, expires_at) + + except Exception as refresh_error: + print(f"šŸ”„ Token refresh FAILED: {refresh_error}", file=sys.stderr) + raise + else: + print(f"šŸ”„ Credentials are valid, no refresh needed", file=sys.stderr) + + # Cache the credentials for reuse + _cached_credentials = credentials + return credentials + except (FileNotFoundError, KeyError, json.JSONDecodeError) as e: + # Fallback to Application Default Credentials if config file is not found or invalid + print(f"Warning: Could not load OAuth credentials from config file: {e}", file=sys.stderr) + print("Falling back to Application Default Credentials", file=sys.stderr) + (credentials, _) = google.auth.default(scopes=[_READ_ONLY_ANALYTICS_SCOPE]) + return credentials def create_admin_api_client() -> admin_v1beta.AnalyticsAdminServiceAsyncClient: diff --git a/refresh_token.py b/refresh_token.py new file mode 100644 index 0000000..a7bd48d --- /dev/null +++ b/refresh_token.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Script to manually refresh Google OAuth access token.""" + +import json +from google.oauth2.credentials import Credentials +from google.auth.transport.requests import Request + +def refresh_token(): + """Refresh the Google OAuth access token.""" + try: + # Load current config + config_path = 'google-analytics-config (4).json' + with open(config_path, 'r') as f: + config = json.load(f) + + oauth_config = config.get('googleOAuthCredentials', {}) + tokens = config.get('googleAnalyticsTokens', {}) + + print("Current tokens:") + print(f" Access token: {tokens.get('accessToken', 'None')[:50]}...") + print(f" Refresh token: {tokens.get('refreshToken', 'None')[:50]}...") + print(f" Client ID: {oauth_config.get('clientId', 'None')[:50]}...") + + # 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'] + ) + + print(f"\nToken expired: {credentials.expired}") + + if credentials.refresh_token: + print("Attempting to refresh token...") + credentials.refresh(Request()) + + # Update config with new token + config['googleAnalyticsTokens']['accessToken'] = credentials.token + + # Save updated config + with open(config_path, 'w') as f: + json.dump(config, f, indent=2) + + print("āœ… Token refreshed successfully!") + print(f"New access token: {credentials.token[:50]}...") + print("Config file updated.") + + else: + print("āŒ No refresh token available. Need to re-authenticate.") + + except Exception as e: + print(f"āŒ Error refreshing token: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + refresh_token() 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 diff --git a/run_mcp_server.py b/run_mcp_server.py new file mode 100755 index 0000000..85b584d --- /dev/null +++ b/run_mcp_server.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Wrapper to run the MCP server with proper environment.""" + +import sys +import os + +# Set up paths +project_root = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, project_root) + +try: + # Import and run the server with explicit config path + from analytics_mcp.server import run_server + + # Default config path + default_config = os.path.join(project_root, 'google-analytics-config (4).json') + + # Use command line arg if provided, otherwise use default + config_path = sys.argv[1] if len(sys.argv) > 1 else default_config + + if not os.path.exists(config_path): + print(f"Error: Config file not found: {config_path}", file=sys.stderr) + print(f"Usage: {sys.argv[0]} [config_path]", file=sys.stderr) + sys.exit(1) + + print(f"Starting MCP server with config: {config_path}", file=sys.stderr) + run_server(config_path) + +except Exception as e: + print(f"Server crashed with error: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + sys.exit(1) \ No newline at end of file diff --git a/run_server.sh b/run_server.sh new file mode 100755 index 0000000..9b8d12e --- /dev/null +++ b/run_server.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Set the project root directory +PROJECT_ROOT="$(dirname "$0")" + +# Export the config path +export GOOGLE_ANALYTICS_CONFIG_PATH="$PROJECT_ROOT/google-analytics-config (4).json" + +# Add the project root to PYTHONPATH +export PYTHONPATH="$PROJECT_ROOT:$PYTHONPATH" + +# Install dependencies if needed (only once) +if ! /opt/homebrew/bin/python3 -c "import mcp" 2>/dev/null; then + echo "Installing dependencies..." >&2 + /opt/homebrew/bin/python3 -m pip install --user google-analytics-admin google-analytics-data google-auth mcp httpx +fi + +# Run the server +exec /opt/homebrew/bin/python3 "$PROJECT_ROOT/analytics_mcp/server.py" \ No newline at end of file diff --git a/test_oauth.py b/test_oauth.py new file mode 100644 index 0000000..e6f80ae --- /dev/null +++ b/test_oauth.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Test script to verify OAuth credentials are working.""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from analytics_mcp.coordinator import set_config_path +from analytics_mcp.tools.utils import _create_credentials, create_data_api_client + +def test_oauth_credentials(config_path: str): + print("Testing OAuth credentials...") + + try: + # Set the config path + set_config_path(config_path) + + # Test credential creation + credentials = _create_credentials() + print(f"āœ“ Credentials created successfully") + print(f" - Has access token: {bool(credentials.token)}") + print(f" - Has refresh token: {bool(credentials.refresh_token)}") + print(f" - Client ID: {credentials.client_id[:20]}..." if credentials.client_id else " - Client ID: None") + + # Test API client creation + client = create_data_api_client() + print(f"āœ“ Analytics Data API client created successfully") + + print("\nāœ… OAuth configuration is working correctly!") + + except Exception as e: + print(f"\nāŒ Error: {e}") + print(f" Type: {type(e).__name__}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + config_path = sys.argv[1] if len(sys.argv) > 1 else "google-analytics-config (4).json" + test_oauth_credentials(config_path) \ No newline at end of file From 0a6c1f065c05ce61a37d206fa7485fde9e259a17 Mon Sep 17 00:00:00 2001 From: Menelaos Kotsollaris Date: Thu, 28 Aug 2025 13:58:12 -0400 Subject: [PATCH 2/8] cleanup instructions --- run_mcp_server.py | 12 +++++++----- run_server.sh | 22 ++++++++++++++++++---- test_oauth.py | 39 --------------------------------------- 3 files changed, 25 insertions(+), 48 deletions(-) delete mode 100644 test_oauth.py diff --git a/run_mcp_server.py b/run_mcp_server.py index 85b584d..81fdb2f 100755 --- a/run_mcp_server.py +++ b/run_mcp_server.py @@ -12,15 +12,17 @@ # Import and run the server with explicit config path from analytics_mcp.server import run_server - # Default config path - default_config = os.path.join(project_root, 'google-analytics-config (4).json') + # Config path is required as command line argument + if len(sys.argv) < 2: + print(f"Error: Config file path is required", file=sys.stderr) + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) - # Use command line arg if provided, otherwise use default - config_path = sys.argv[1] if len(sys.argv) > 1 else default_config + config_path = sys.argv[1] if not os.path.exists(config_path): print(f"Error: Config file not found: {config_path}", file=sys.stderr) - print(f"Usage: {sys.argv[0]} [config_path]", file=sys.stderr) + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) print(f"Starting MCP server with config: {config_path}", file=sys.stderr) diff --git a/run_server.sh b/run_server.sh index 9b8d12e..887431c 100755 --- a/run_server.sh +++ b/run_server.sh @@ -3,8 +3,20 @@ # Set the project root directory PROJECT_ROOT="$(dirname "$0")" -# Export the config path -export GOOGLE_ANALYTICS_CONFIG_PATH="$PROJECT_ROOT/google-analytics-config (4).json" +# Check if config path is provided as argument +if [ $# -eq 0 ]; then + echo "Error: Config file path is required" >&2 + echo "Usage: $0 " >&2 + exit 1 +fi + +CONFIG_PATH="$1" + +# Check if config file exists +if [ ! -f "$CONFIG_PATH" ]; then + echo "Error: Config file not found: $CONFIG_PATH" >&2 + exit 1 +fi # Add the project root to PYTHONPATH export PYTHONPATH="$PROJECT_ROOT:$PYTHONPATH" @@ -15,5 +27,7 @@ if ! /opt/homebrew/bin/python3 -c "import mcp" 2>/dev/null; then /opt/homebrew/bin/python3 -m pip install --user google-analytics-admin google-analytics-data google-auth mcp httpx fi -# Run the server -exec /opt/homebrew/bin/python3 "$PROJECT_ROOT/analytics_mcp/server.py" \ No newline at end of file +echo "Starting MCP server with config: $CONFIG_PATH" >&2 + +# Run the server with config path +exec /opt/homebrew/bin/python3 "$PROJECT_ROOT/run_mcp_server.py" "$CONFIG_PATH" \ No newline at end of file diff --git a/test_oauth.py b/test_oauth.py deleted file mode 100644 index e6f80ae..0000000 --- a/test_oauth.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -"""Test script to verify OAuth credentials are working.""" - -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from analytics_mcp.coordinator import set_config_path -from analytics_mcp.tools.utils import _create_credentials, create_data_api_client - -def test_oauth_credentials(config_path: str): - print("Testing OAuth credentials...") - - try: - # Set the config path - set_config_path(config_path) - - # Test credential creation - credentials = _create_credentials() - print(f"āœ“ Credentials created successfully") - print(f" - Has access token: {bool(credentials.token)}") - print(f" - Has refresh token: {bool(credentials.refresh_token)}") - print(f" - Client ID: {credentials.client_id[:20]}..." if credentials.client_id else " - Client ID: None") - - # Test API client creation - client = create_data_api_client() - print(f"āœ“ Analytics Data API client created successfully") - - print("\nāœ… OAuth configuration is working correctly!") - - except Exception as e: - print(f"\nāŒ Error: {e}") - print(f" Type: {type(e).__name__}") - import traceback - traceback.print_exc() - -if __name__ == "__main__": - config_path = sys.argv[1] if len(sys.argv) > 1 else "google-analytics-config (4).json" - test_oauth_credentials(config_path) \ No newline at end of file From 6b6fcbb0fa96c5c34826775851a1a3d7b22d1e76 Mon Sep 17 00:00:00 2001 From: Menelaos Kotsollaris Date: Wed, 3 Sep 2025 21:41:34 -0400 Subject: [PATCH 3/8] update refresh_and_update_config.py --- refresh_and_update_config.py | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 refresh_and_update_config.py diff --git a/refresh_and_update_config.py b/refresh_and_update_config.py new file mode 100644 index 0000000..6d4648f --- /dev/null +++ b/refresh_and_update_config.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Script to refresh Google OAuth token and update config with expiry.""" + +import json +import time +from google.oauth2.credentials import Credentials +from google.auth.transport.requests import Request + +def refresh_and_update_config(): + """Refresh the Google OAuth access token and update config.""" + config_path = '/Users/mkotsollaris/projects/google-analytics-mcp/google-analytics-config (4).json' + + try: + # Load current config + with open(config_path, 'r') as f: + config = json.load(f) + + oauth_config = config.get('googleOAuthCredentials', {}) + tokens = config.get('googleAnalyticsTokens', {}) + + print("šŸ”„ Refreshing OAuth token...") + print(f"Current access token: {tokens.get('accessToken', 'None')[:50]}...") + + # 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'] + ) + + # Force refresh the token + print("šŸ”„ Refreshing token...") + credentials.refresh(Request()) + + # Calculate expiry time (1 hour from now - typical OAuth token expiry) + expires_at = int(time.time()) + 3600 + + # Update config with new token and expiry + config['googleAnalyticsTokens']['accessToken'] = credentials.token + config['googleAnalyticsTokens']['expiresAt'] = expires_at + + # Save updated config + with open(config_path, 'w') as f: + json.dump(config, f, indent=2) + + print("āœ… Token refreshed successfully!") + print(f"New access token: {credentials.token[:50]}...") + print(f"Expires at: {expires_at} ({time.ctime(expires_at)})") + print("Config file updated.") + + return True + + except Exception as e: + print(f"āŒ Error refreshing token: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + success = refresh_and_update_config() + if success: + print("\nšŸŽ‰ Token refresh complete! You can now restart your MCP server.") + else: + print("\nšŸ’„ Token refresh failed. Check the error messages above.") \ No newline at end of file From 82df0cb7d55a0b8d9f1734410b929cbe6d77491a Mon Sep 17 00:00:00 2001 From: Menelaos Kotsollaris Date: Thu, 4 Sep 2025 08:31:11 -0400 Subject: [PATCH 4/8] add readme --- README.md | 43 ++++++---- analytics_mcp/server.py | 13 +-- analytics_mcp/tools/utils.py | 148 ++++++++++++++++++----------------- config.example.json | 12 +++ run_mcp_server.py | 27 +++---- 5 files changed, 136 insertions(+), 107 deletions(-) create mode 100644 config.example.json diff --git a/README.md b/README.md index 8b6371f..2a39751 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Google Analytics MCP Server (Experimental) -> **Note**: This is a fork modified to work with OAuth2 Access Tokens + Refresh Tokens instead of Application Default Credentials for easier integration and token management. [![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/) @@ -68,9 +67,11 @@ to enable the following APIs in your Google Cloud project: ### Configure credentials šŸ”‘ -This server uses OAuth2 credentials with access and refresh tokens instead of Application Default Credentials (ADC). You'll need to create a configuration file with your OAuth credentials and tokens. +This server supports two authentication methods: -#### Option 1: Using OAuth2 Config File (Recommended) +#### Option 1: OAuth2 with Access/Refresh Tokens (Recommended for integrations) + +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. Create a JSON configuration file with your OAuth credentials and tokens: @@ -98,9 +99,11 @@ To obtain OAuth credentials: https://www.googleapis.com/auth/analytics.readonly ``` -#### Option 2: Fallback to Application Default Credentials +#### Option 2: Application Default Credentials (ADC) + +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). -If no config file is provided, the server will fallback to [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc). +To set up ADC: ```shell gcloud auth application-default login \ @@ -116,7 +119,7 @@ gcloud auth application-default login \ 3. Add the analytics-mcp server to the `mcpServers` list: - **For OAuth2 Config File (Recommended):** + **With OAuth2 Config File:** ```json { "mcpServers": { @@ -124,30 +127,25 @@ gcloud auth application-default login \ "command": "python", "args": [ "-m", "analytics_mcp.server", - "/path/to/your/google-analytics-config.json" + "--config", "/path/to/your/google-analytics-config.json" ] } } } ``` - **For Direct Python Execution:** + **With Application Default Credentials:** ```json { "mcpServers": { "analytics-mcp": { - "command": "/path/to/python", - "args": [ - "/path/to/analytics-mcp/run_mcp_server.py", - "/path/to/your/google-analytics-config.json" - ] + "command": "python", + "args": ["-m", "analytics_mcp.server"] } } } ``` - Replace `/path/to/your/google-analytics-config.json` with the full path to your OAuth configuration file. - ### Configure Gemini (Alternative) For Gemini CLI users: @@ -156,6 +154,7 @@ For Gemini CLI users: 2. Create or edit the file at `~/.gemini/settings.json`: + **With OAuth2 Config File:** ```json { "mcpServers": { @@ -164,13 +163,25 @@ For Gemini CLI users: "args": [ "run", "analytics-mcp", - "/path/to/your/google-analytics-config.json" + "--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) diff --git a/analytics_mcp/server.py b/analytics_mcp/server.py index 763819e..3375a68 100755 --- a/analytics_mcp/server.py +++ b/analytics_mcp/server.py @@ -38,7 +38,8 @@ def run_server(config_path: str = None) -> None: """Runs the server. Args: - config_path: Path to the Google Analytics configuration file. + 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. """ @@ -52,15 +53,17 @@ def run_server(config_path: str = None) -> None: parser.add_argument( "--config", type=str, - help="Path to Google Analytics configuration file", + 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() - if not args.config: - print("Error: Config file path required. Use --config or set GOOGLE_ANALYTICS_CONFIG_PATH", file=sys.stderr) - sys.exit(1) + # 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) diff --git a/analytics_mcp/tools/utils.py b/analytics_mcp/tools/utils.py index 30cf6fa..d9900b0 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -18,7 +18,6 @@ import json import os import sys -import time from google.analytics import admin_v1beta, data_v1beta from google.api_core.gapic_v1.client_info import ClientInfo @@ -57,87 +56,100 @@ def _get_package_version_with_fallback(): def _update_config_file(config_path: str, new_access_token: str, expires_at: int): """Update the config file with new access token and expiry.""" try: - print(f"Attempting to update config file: {config_path}", file=sys.stderr) - with open(config_path, 'r') as f: config = json.load(f) - # Store old token for debugging - old_token = config['googleAnalyticsTokens']['accessToken'][:50] + "..." - config['googleAnalyticsTokens']['accessToken'] = new_access_token config['googleAnalyticsTokens']['expiresAt'] = expires_at with open(config_path, 'w') as f: json.dump(config, f, indent=2) - - print(f"āœ… Successfully updated config file", file=sys.stderr) - print(f" Old token: {old_token}", file=sys.stderr) - print(f" New token: {new_access_token[:50]}...", file=sys.stderr) - print(f" Expires at: {expires_at}", file=sys.stderr) - except Exception as e: - print(f"āŒ Failed to update config file: {e}", file=sys.stderr) - import traceback - traceback.print_exc(file=sys.stderr) + # Log error but don't fail - let the caller handle it + print(f"Warning: Failed to update config file: {e}", file=sys.stderr) def _create_credentials() -> google.auth.credentials.Credentials: - """Returns OAuth2 credentials from config file with read-only scope and auto-refresh.""" + """Returns Google Analytics API credentials. + + Supports two authentication methods: + 1. OAuth2 with access/refresh tokens from config file (preferred) + 2. Application Default Credentials (fallback) + + The config file format for OAuth2: + { + "googleOAuthCredentials": { + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET" + }, + "googleAnalyticsTokens": { + "accessToken": "YOUR_ACCESS_TOKEN", + "refreshToken": "YOUR_REFRESH_TOKEN", + "expiresAt": UNIX_TIMESTAMP + } + } + """ global _cached_credentials - print(f"šŸ”„ _create_credentials called", file=sys.stderr) + # Return cached credentials if still valid + if _cached_credentials and not _cached_credentials.expired: + return _cached_credentials + + # Try to get config path from coordinator or environment + config_path = _get_config_path() - # FORCE REFRESH - don't use cached credentials for debugging - # if _cached_credentials and not _cached_credentials.expired: - # return _cached_credentials + if config_path: + # Attempt OAuth2 authentication from config file + credentials = _try_oauth_authentication(config_path) + if credentials: + _cached_credentials = credentials + return credentials + # Fallback to Application Default Credentials + print("Using Application Default Credentials", file=sys.stderr) + (credentials, _) = google.auth.default(scopes=[_READ_ONLY_ANALYTICS_SCOPE]) + _cached_credentials = credentials + return credentials + + +def _get_config_path() -> str: + """Get the config file path from coordinator or environment.""" # Import here to avoid circular imports from ..coordinator import get_config_path try: - config_path = get_config_path() - print(f"šŸ”„ Using config path from coordinator: {config_path}", file=sys.stderr) - except RuntimeError as e: - print(f"šŸ”„ Coordinator not initialized: {e}", file=sys.stderr) + return get_config_path() + except RuntimeError: # Fallback to environment variable if coordinator not initialized - config_path = os.environ.get('GOOGLE_ANALYTICS_CONFIG_PATH') - print(f"šŸ”„ Using env config path: {config_path}", file=sys.stderr) - if not config_path: - raise RuntimeError("No config path provided. Set GOOGLE_ANALYTICS_CONFIG_PATH environment variable.") - - print(f"šŸ”„ Loading config from: {config_path}", file=sys.stderr) + return os.environ.get('GOOGLE_ANALYTICS_CONFIG_PATH') + + +def _try_oauth_authentication(config_path: str) -> google.auth.credentials.Credentials: + """Try to authenticate using OAuth2 credentials from config file. + Returns: + Credentials object if successful, None otherwise. + """ try: with open(config_path, 'r') as f: config = json.load(f) - print(f"šŸ”„ Config loaded successfully", file=sys.stderr) - - oauth_config = config.get('googleOAuthCredentials', {}) - tokens = config.get('googleAnalyticsTokens', {}) - - # Check if token is expired before creating credentials - expires_at = tokens.get('expiresAt') - current_time = int(time.time()) - - print(f"šŸ”„ Token expires at: {expires_at}, current time: {current_time}", file=sys.stderr) + oauth_config = config.get('googleOAuthCredentials') + tokens = config.get('googleAnalyticsTokens') - # If we have expiry info and token is not expired, use current token - if expires_at and current_time < expires_at: - print(f"šŸ”„ Using existing valid access token", file=sys.stderr) - else: - print(f"šŸ”„ Access token expired or no expiry info, will refresh", file=sys.stderr) + # Check if we have OAuth configuration + if not oauth_config or not tokens: + return None access_token = tokens.get('accessToken') refresh_token = tokens.get('refreshToken') client_id = oauth_config.get('clientId') client_secret = oauth_config.get('clientSecret') - print(f"šŸ”„ Creating credentials with:", file=sys.stderr) - print(f" Access token: {access_token[:50] if access_token else 'NONE'}...", file=sys.stderr) - print(f" Refresh token: {refresh_token[:50] if refresh_token else 'NONE'}...", file=sys.stderr) - print(f" Client ID: {client_id}", file=sys.stderr) + # Validate required fields + if not all([access_token, refresh_token, client_id, client_secret]): + print("OAuth config incomplete, missing required fields", file=sys.stderr) + return None credentials = Credentials( token=access_token, @@ -148,35 +160,27 @@ def _create_credentials() -> google.auth.credentials.Credentials: scopes=[_READ_ONLY_ANALYTICS_SCOPE] ) - print(f"šŸ”„ Credentials created. Expired: {credentials.expired}, Valid: {credentials.valid}", file=sys.stderr) - - # FORCE REFRESH - Always refresh if no expiry info or if expired - if (not expires_at or credentials.expired) and credentials.refresh_token: - print(f"šŸ”„ Forcing token refresh (no expiry info or expired)...", file=sys.stderr) + # Refresh token if expired or no expiry info + expires_at = tokens.get('expiresAt') + if not expires_at or credentials.expired: try: credentials.refresh(Request()) - print(f"šŸ”„ Token refresh SUCCESS! New token: {credentials.token[:50]}...", file=sys.stderr) - # Update the config file with new token if credentials.token and credentials.expiry: - expires_at = int(credentials.expiry.timestamp()) - _update_config_file(config_path, credentials.token, expires_at) - - except Exception as refresh_error: - print(f"šŸ”„ Token refresh FAILED: {refresh_error}", file=sys.stderr) - raise - else: - print(f"šŸ”„ Credentials are valid, no refresh needed", file=sys.stderr) + new_expires_at = int(credentials.expiry.timestamp()) + _update_config_file(config_path, credentials.token, new_expires_at) + except Exception as e: + print(f"Failed to refresh token: {e}", file=sys.stderr) + return None - # Cache the credentials for reuse - _cached_credentials = credentials - return credentials - except (FileNotFoundError, KeyError, json.JSONDecodeError) as e: - # Fallback to Application Default Credentials if config file is not found or invalid - print(f"Warning: Could not load OAuth credentials from config file: {e}", file=sys.stderr) - print("Falling back to Application Default Credentials", file=sys.stderr) - (credentials, _) = google.auth.default(scopes=[_READ_ONLY_ANALYTICS_SCOPE]) return credentials + + except (FileNotFoundError, json.JSONDecodeError) as e: + print(f"Could not load OAuth config from file: {e}", file=sys.stderr) + return None + except Exception as e: + print(f"Unexpected error during OAuth authentication: {e}", file=sys.stderr) + return None def create_admin_api_client() -> admin_v1beta.AnalyticsAdminServiceAsyncClient: 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/run_mcp_server.py b/run_mcp_server.py index 81fdb2f..f93fb9c 100755 --- a/run_mcp_server.py +++ b/run_mcp_server.py @@ -9,23 +9,22 @@ sys.path.insert(0, project_root) try: - # Import and run the server with explicit config path + # Import and run the server from analytics_mcp.server import run_server - # Config path is required as command line argument - if len(sys.argv) < 2: - print(f"Error: Config file path is required", file=sys.stderr) - print(f"Usage: {sys.argv[0]} ", file=sys.stderr) - sys.exit(1) + # Config path is optional - if provided, must exist + config_path = None + if len(sys.argv) > 1: + config_path = sys.argv[1] + if not os.path.exists(config_path): + print(f"Error: Config file not found: {config_path}", file=sys.stderr) + print(f"Usage: {sys.argv[0]} [config_path]", file=sys.stderr) + print(f" config_path: Optional OAuth config file (uses ADC if not provided)", file=sys.stderr) + sys.exit(1) + print(f"Starting MCP server with OAuth config: {config_path}", file=sys.stderr) + else: + print(f"Starting MCP server with Application Default Credentials", file=sys.stderr) - config_path = sys.argv[1] - - if not os.path.exists(config_path): - print(f"Error: Config file not found: {config_path}", file=sys.stderr) - print(f"Usage: {sys.argv[0]} ", file=sys.stderr) - sys.exit(1) - - print(f"Starting MCP server with config: {config_path}", file=sys.stderr) run_server(config_path) except Exception as e: From 64b422ab9db749fa8d9c65f9249b90e4021578ff Mon Sep 17 00:00:00 2001 From: Menelaos Kotsollaris Date: Wed, 1 Oct 2025 09:10:39 -0400 Subject: [PATCH 5/8] latest working --- analytics_mcp/coordinator.py | 35 ++++- analytics_mcp/tools/admin/info.py | 58 +++++--- analytics_mcp/tools/reporting/core.py | 5 +- analytics_mcp/tools/reporting/metadata.py | 10 +- analytics_mcp/tools/reporting/realtime.py | 6 +- analytics_mcp/tools/utils.py | 169 ++++++++++++++++++++-- refresh_and_update_config.py | 9 +- run_mcp_server.py | 66 ++++++++- 8 files changed, 304 insertions(+), 54 deletions(-) diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index 62af343..b2d3e60 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -28,8 +28,41 @@ def set_config_path(config_path: str): """Set the global config path for the MCP server.""" global _config_path - if not os.path.exists(config_path): + try: + # Test if we can actually read the file (not just check if it exists) + with open(config_path, 'r') as f: + f.read(1) # Try to read at least 1 byte + except FileNotFoundError: raise FileNotFoundError(f"Config file not found: {config_path}") + except PermissionError as e: + print(f"Permission denied accessing config file: {config_path}", file=sys.stderr) + print(f"Error: {e}", file=sys.stderr) + print("", file=sys.stderr) + print("SOLUTION: Move your config file to an accessible location:", file=sys.stderr) + print(f" cp '{config_path}' '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/'", file=sys.stderr) + print("Then run the MCP server with the new path:", file=sys.stderr) + print(f" python run_mcp_server.py '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/{os.path.basename(config_path)}'", file=sys.stderr) + print("", file=sys.stderr) + print("Alternatively, grant your Terminal app access to the Desktop folder:", file=sys.stderr) + print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable Desktop", file=sys.stderr) + raise PermissionError(f"Cannot access config file: {config_path}") + except Exception as e: + error_msg = str(e).lower() + if "operation not permitted" in error_msg or "permission denied" in error_msg: + print(f"Permission error accessing config file: {config_path}", file=sys.stderr) + print(f"Error: {e}", file=sys.stderr) + print("", file=sys.stderr) + print("SOLUTION: Move your config file to an accessible location:", file=sys.stderr) + print(f" cp '{config_path}' '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/'", file=sys.stderr) + print("Then run the MCP server with the new path:", file=sys.stderr) + print(f" python run_mcp_server.py '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/{os.path.basename(config_path)}'", file=sys.stderr) + print("", file=sys.stderr) + print("Alternatively, grant your Terminal app access to the Desktop folder:", file=sys.stderr) + print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable Desktop", file=sys.stderr) + raise PermissionError(f"Cannot access config file: {config_path}") + else: + raise e + _config_path = config_path print(f"MCP server using config: {config_path}", file=sys.stderr) 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 d9900b0..6558f61 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -14,18 +14,26 @@ """Common utilities used by the MCP server.""" -from typing import Any, Dict +from typing import Any, Dict, Callable, TypeVar, Awaitable import json +import logging import os 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 google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from importlib import metadata import google.auth import proto +from datetime import datetime, timezone + +T = TypeVar('T') + +# Configure logger +logger = logging.getLogger(__name__) def _get_package_version_with_fallback(): @@ -51,6 +59,66 @@ def _get_package_version_with_fallback(): # Global credentials cache to avoid recreating credentials _cached_credentials = None +_cached_admin_client = None +_cached_data_client = None + +def invalidate_cached_credentials(): + """Invalidate cached credentials to force refresh on next request.""" + global _cached_credentials, _cached_admin_client, _cached_data_client + logger.info("Invalidating cached credentials and clients") + _cached_credentials = None + _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. + + 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() + + # 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: + print(f"šŸ”„ Authentication error detected, refreshing credentials and retrying... (attempt {attempt + 1}/{max_retries + 1})", file=sys.stderr) + invalidate_cached_credentials() + continue + else: + print(f"āŒ Authentication failed after {max_retries + 1} attempts: {e}", file=sys.stderr) + 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 + raise + + # This shouldn't be reached, but just in case + if last_exception: + raise last_exception def _update_config_file(config_path: str, new_access_token: str, expires_at: int): @@ -71,11 +139,11 @@ def _update_config_file(config_path: str, new_access_token: str, expires_at: int def _create_credentials() -> google.auth.credentials.Credentials: """Returns Google Analytics API credentials. - + Supports two authentication methods: 1. OAuth2 with access/refresh tokens from config file (preferred) 2. Application Default Credentials (fallback) - + The config file format for OAuth2: { "googleOAuthCredentials": { @@ -90,13 +158,17 @@ def _create_credentials() -> google.auth.credentials.Credentials: } """ global _cached_credentials - + # Return cached credentials if still valid if _cached_credentials and not _cached_credentials.expired: + logger.debug("Using cached credentials (not expired)") return _cached_credentials - + elif _cached_credentials: + logger.info("Cached credentials expired, recreating...") + # Try to get config path from coordinator or environment config_path = _get_config_path() + logger.debug(f"Config path: {config_path}") if config_path: # Attempt OAuth2 authentication from config file @@ -126,13 +198,15 @@ def _get_config_path() -> str: def _try_oauth_authentication(config_path: str) -> google.auth.credentials.Credentials: """Try to authenticate using OAuth2 credentials from config file. - + 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) + logger.debug("Successfully loaded config file") oauth_config = config.get('googleOAuthCredentials') tokens = config.get('googleAnalyticsTokens') @@ -150,36 +224,75 @@ def _try_oauth_authentication(config_path: str) -> google.auth.credentials.Crede if not all([access_token, refresh_token, client_id, client_secret]): print("OAuth config incomplete, missing required fields", file=sys.stderr) return None - + + # Convert expiresAt timestamp to datetime if available + # Note: Use naive datetime (no timezone) to match what google.auth.credentials expects + expires_at = tokens.get('expiresAt') + expiry = None + if expires_at: + expiry = datetime.utcfromtimestamp(expires_at) + 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] + scopes=[_READ_ONLY_ANALYTICS_SCOPE], + expiry=expiry ) - + # Refresh token if expired or no expiry info - expires_at = tokens.get('expiresAt') if not expires_at or credentials.expired: + logger.info(f"Token expired or missing expiry, refreshing... (expired={credentials.expired})") try: 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})") except Exception as e: - print(f"Failed to refresh token: {e}", file=sys.stderr) + logger.error(f"Failed to refresh token: {e}") + import traceback + traceback.print_exc() return None + else: + logger.debug(f"Using cached token (expires at {expires_at})") return credentials except (FileNotFoundError, json.JSONDecodeError) as e: print(f"Could not load OAuth config from file: {e}", file=sys.stderr) return None + except PermissionError as e: + print(f"Permission denied accessing config file: {config_path}", file=sys.stderr) + print(f"Error: {e}", file=sys.stderr) + print("", file=sys.stderr) + print("SOLUTION: Move your config file to an accessible location:", file=sys.stderr) + print(f" cp '{config_path}' '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/'", file=sys.stderr) + print("Then run the MCP server with the new path:", file=sys.stderr) + print(f" python run_mcp_server.py '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/{os.path.basename(config_path)}'", file=sys.stderr) + print("", file=sys.stderr) + print("Alternatively, grant your Terminal app access to the Desktop folder:", file=sys.stderr) + print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable Desktop", file=sys.stderr) + return None except Exception as e: - print(f"Unexpected error during OAuth authentication: {e}", file=sys.stderr) + error_msg = str(e).lower() + if "operation not permitted" in error_msg or "permission denied" in error_msg: + print(f"Permission error accessing config file: {config_path}", file=sys.stderr) + print(f"Error: {e}", file=sys.stderr) + print("", file=sys.stderr) + print("SOLUTION: Move your config file to an accessible location:", file=sys.stderr) + print(f" cp '{config_path}' '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/'", file=sys.stderr) + print("Then run the MCP server with the new path:", file=sys.stderr) + print(f" python run_mcp_server.py '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/{os.path.basename(config_path)}'", file=sys.stderr) + print("", file=sys.stderr) + print("Alternatively, grant your Terminal app access to the Desktop folder:", file=sys.stderr) + print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable Desktop", file=sys.stderr) + else: + print(f"Unexpected error during OAuth authentication: {e}", file=sys.stderr) return None @@ -188,9 +301,21 @@ def create_admin_api_client() -> admin_v1beta.AnalyticsAdminServiceAsyncClient: Uses Application Default Credentials with read-only scope. """ - return admin_v1beta.AnalyticsAdminServiceAsyncClient( - client_info=_CLIENT_INFO, credentials=_create_credentials() + global _cached_admin_client + + # Return cached client if credentials are still valid + if _cached_admin_client: + creds = _create_credentials() + if not creds.expired: + logger.debug("Reusing cached Admin API client") + return _cached_admin_client + + logger.debug("Creating new Admin API client") + creds = _create_credentials() + _cached_admin_client = admin_v1beta.AnalyticsAdminServiceAsyncClient( + client_info=_CLIENT_INFO, credentials=creds ) + return _cached_admin_client def create_data_api_client() -> data_v1beta.BetaAnalyticsDataAsyncClient: @@ -198,9 +323,21 @@ def create_data_api_client() -> data_v1beta.BetaAnalyticsDataAsyncClient: Uses Application Default Credentials with read-only scope. """ - return data_v1beta.BetaAnalyticsDataAsyncClient( - client_info=_CLIENT_INFO, credentials=_create_credentials() + global _cached_data_client + + # Return cached client if credentials are still valid + if _cached_data_client: + creds = _create_credentials() + if not creds.expired: + logger.debug("Reusing cached Data API client") + return _cached_data_client + + logger.debug("Creating new Data API client") + creds = _create_credentials() + _cached_data_client = data_v1beta.BetaAnalyticsDataAsyncClient( + client_info=_CLIENT_INFO, credentials=creds ) + return _cached_data_client def construct_property_rn(property_value: int | str) -> str: diff --git a/refresh_and_update_config.py b/refresh_and_update_config.py index 6d4648f..dcb3876 100644 --- a/refresh_and_update_config.py +++ b/refresh_and_update_config.py @@ -2,13 +2,15 @@ """Script to refresh Google OAuth token and update config with expiry.""" import json +import sys import time from google.oauth2.credentials import Credentials from google.auth.transport.requests import Request -def refresh_and_update_config(): +def refresh_and_update_config(config_path=None): """Refresh the Google OAuth access token and update config.""" - config_path = '/Users/mkotsollaris/projects/google-analytics-mcp/google-analytics-config (4).json' + if not config_path: + config_path = '/Users/mkotsollaris/Projects/google-analytics-config.json' try: # Load current config @@ -60,7 +62,8 @@ def refresh_and_update_config(): return False if __name__ == "__main__": - success = refresh_and_update_config() + config_path = sys.argv[1] if len(sys.argv) > 1 else None + success = refresh_and_update_config(config_path) if success: print("\nšŸŽ‰ Token refresh complete! You can now restart your MCP server.") else: diff --git a/run_mcp_server.py b/run_mcp_server.py index f93fb9c..5079cce 100755 --- a/run_mcp_server.py +++ b/run_mcp_server.py @@ -12,7 +12,7 @@ # Import and run the server from analytics_mcp.server import run_server - # Config path is optional - if provided, must exist + # Config path is optional - if provided, must exist and be accessible config_path = None if len(sys.argv) > 1: config_path = sys.argv[1] @@ -21,14 +21,68 @@ print(f"Usage: {sys.argv[0]} [config_path]", file=sys.stderr) print(f" config_path: Optional OAuth config file (uses ADC if not provided)", file=sys.stderr) sys.exit(1) - print(f"Starting MCP server with OAuth config: {config_path}", file=sys.stderr) + + # Test file accessibility early to provide helpful error messages + try: + with open(config_path, 'r') as f: + f.read(1) # Try to read at least 1 byte + except PermissionError: + print(f"\nāŒ ERROR: Permission denied accessing config file: {config_path}", file=sys.stderr) + print(f"āŒ This usually happens when the config file is on Desktop/Documents and Terminal lacks folder access.", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ”§ SOLUTION 1 (Recommended): Move your config file to an accessible location:", file=sys.stderr) + print(f" cp '{config_path}' '{project_root}/'", file=sys.stderr) + print(" Then run the MCP server with the new path:", file=sys.stderr) + print(f" python {sys.argv[0]} '{project_root}/{os.path.basename(config_path)}'", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ”§ SOLUTION 2: Grant Terminal app access to the folder containing your config:", file=sys.stderr) + print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable folder access", file=sys.stderr) + print(" (Note: You may need to restart Terminal after granting permissions)", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ’” TIP: For MCP servers, it's best practice to keep config files in the project directory.", file=sys.stderr) + sys.exit(1) + except Exception as e: + error_msg = str(e).lower() + if "operation not permitted" in error_msg or "permission denied" in error_msg: + print(f"\nāŒ ERROR: Permission error accessing config file: {config_path}", file=sys.stderr) + print(f"āŒ Details: {e}", file=sys.stderr) + print(f"āŒ This usually happens when the config file is on Desktop/Documents and Terminal lacks folder access.", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ”§ SOLUTION 1 (Recommended): Move your config file to an accessible location:", file=sys.stderr) + print(f" cp '{config_path}' '{project_root}/'", file=sys.stderr) + print(" Then run the MCP server with the new path:", file=sys.stderr) + print(f" python {sys.argv[0]} '{project_root}/{os.path.basename(config_path)}'", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ”§ SOLUTION 2: Grant Terminal app access to the folder containing your config:", file=sys.stderr) + print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable folder access", file=sys.stderr) + print(" (Note: You may need to restart Terminal after granting permissions)", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ’” TIP: For MCP servers, it's best practice to keep config files in the project directory.", file=sys.stderr) + sys.exit(1) + + print(f"āœ… Using OAuth config file: {config_path}", file=sys.stderr) else: - print(f"Starting MCP server with Application Default Credentials", file=sys.stderr) + print(f"āœ… Starting MCP server with Application Default Credentials", file=sys.stderr) run_server(config_path) except Exception as e: - print(f"Server crashed with error: {e}", file=sys.stderr) - import traceback - traceback.print_exc(file=sys.stderr) + error_msg = str(e).lower() + if "cannot access config file" in error_msg and ("permission" in error_msg or "operation not permitted" in error_msg): + print(f"\nāŒ Server failed to start: {e}", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ”§ This is likely a file permission issue. Please try one of these solutions:", file=sys.stderr) + print(" 1. Move your config file to the project directory", file=sys.stderr) + print(" 2. Grant Terminal app access to the folder containing your config file", file=sys.stderr) + print(" 3. Run the server from the folder containing your config file", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ’” For detailed instructions, run the server again to see the specific error message.", file=sys.stderr) + else: + print(f"\nāŒ Server crashed with error: {e}", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ” Full error details:", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + print("", file=sys.stderr) + print("šŸ’” If this persists, check the server logs or file an issue on GitHub.", file=sys.stderr) sys.exit(1) \ No newline at end of file From a9c512e342fb7bc1beba39665820c865892f39b4 Mon Sep 17 00:00:00 2001 From: Menelaos Kotsollaris Date: Wed, 1 Oct 2025 09:26:41 -0400 Subject: [PATCH 6/8] cleanup --- analytics_mcp/auth.py | 174 ++++++++++++++++++++++++++++ analytics_mcp/coordinator.py | 70 +++++------ analytics_mcp/tools/utils.py | 219 ++++------------------------------- run_mcp_server.py | 94 +++++++-------- 4 files changed, 264 insertions(+), 293 deletions(-) create mode 100644 analytics_mcp/auth.py diff --git a/analytics_mcp/auth.py b/analytics_mcp/auth.py new file mode 100644 index 0000000..9b5b4c1 --- /dev/null +++ b/analytics_mcp/auth.py @@ -0,0 +1,174 @@ +# 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) -> 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 + + Returns: + Google auth credentials + """ + global _cached_credentials + + # Return cached credentials if still valid + if _cached_credentials and not _cached_credentials.expired: + logger.debug("Using cached credentials (not expired)") + return _cached_credentials + elif _cached_credentials: + logger.info("Cached credentials expired, recreating") + + # 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 + ) + + # Refresh token if expired or no expiry info + if not expires_at or credentials.expired: + logger.info(f"Token expired or missing expiry, refreshing (expired={credentials.expired})") + try: + 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})") + except Exception as e: + logger.error(f"Failed to refresh token: {e}", exc_info=True) + return None + else: + logger.debug(f"Using cached token (expires at {expires_at})") + + 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 b2d3e60..9119f40 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -18,59 +18,43 @@ server using `@mcp.tool` annotations, thereby 'coordinating' the bootstrapping of the server. """ -import os -import sys +import logging +from typing import Optional from mcp.server.fastmcp import FastMCP +logger = logging.getLogger(__name__) + # Global variable to store config path -_config_path = None +_config_path: Optional[str] = None + def set_config_path(config_path: str): - """Set the global config path for the MCP server.""" + """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 - try: - # Test if we can actually read the file (not just check if it exists) - with open(config_path, 'r') as f: - f.read(1) # Try to read at least 1 byte - except FileNotFoundError: - raise FileNotFoundError(f"Config file not found: {config_path}") - except PermissionError as e: - print(f"Permission denied accessing config file: {config_path}", file=sys.stderr) - print(f"Error: {e}", file=sys.stderr) - print("", file=sys.stderr) - print("SOLUTION: Move your config file to an accessible location:", file=sys.stderr) - print(f" cp '{config_path}' '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/'", file=sys.stderr) - print("Then run the MCP server with the new path:", file=sys.stderr) - print(f" python run_mcp_server.py '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/{os.path.basename(config_path)}'", file=sys.stderr) - print("", file=sys.stderr) - print("Alternatively, grant your Terminal app access to the Desktop folder:", file=sys.stderr) - print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable Desktop", file=sys.stderr) - raise PermissionError(f"Cannot access config file: {config_path}") - except Exception as e: - error_msg = str(e).lower() - if "operation not permitted" in error_msg or "permission denied" in error_msg: - print(f"Permission error accessing config file: {config_path}", file=sys.stderr) - print(f"Error: {e}", file=sys.stderr) - print("", file=sys.stderr) - print("SOLUTION: Move your config file to an accessible location:", file=sys.stderr) - print(f" cp '{config_path}' '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/'", file=sys.stderr) - print("Then run the MCP server with the new path:", file=sys.stderr) - print(f" python run_mcp_server.py '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/{os.path.basename(config_path)}'", file=sys.stderr) - print("", file=sys.stderr) - print("Alternatively, grant your Terminal app access to the Desktop folder:", file=sys.stderr) - print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable Desktop", file=sys.stderr) - raise PermissionError(f"Cannot access config file: {config_path}") - else: - raise e + + # 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 - print(f"MCP server using config: {config_path}", file=sys.stderr) + logger.info(f"MCP server using config: {config_path}") + -def get_config_path() -> str: - """Get the global config path for the MCP server.""" - if _config_path is None: - raise RuntimeError("Config path not set. Call set_config_path() first.") +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/tools/utils.py b/analytics_mcp/tools/utils.py index 6558f61..21dac54 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -15,20 +15,16 @@ """Common utilities used by the MCP server.""" from typing import Any, Dict, Callable, TypeVar, Awaitable -import json import logging -import os 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 google.auth.transport.requests import Request -from google.oauth2.credentials import Credentials from importlib import metadata -import google.auth import proto -from datetime import datetime, timezone + +from ..auth import create_credentials, invalidate_cache as invalidate_auth_cache T = TypeVar('T') @@ -52,21 +48,16 @@ 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 credentials cache to avoid recreating credentials -_cached_credentials = None +# Global client cache _cached_admin_client = None _cached_data_client = None + def invalidate_cached_credentials(): """Invalidate cached credentials to force refresh on next request.""" - global _cached_credentials, _cached_admin_client, _cached_data_client - logger.info("Invalidating cached credentials and clients") - _cached_credentials = None + global _cached_admin_client, _cached_data_client + logger.info("Invalidating cached API clients") + invalidate_auth_cache() _cached_admin_client = None _cached_data_client = None @@ -103,11 +94,11 @@ async def retry_on_auth_error(func: Callable[[], Awaitable[T]], max_retries: int 'access token', 'expired', 'refresh' ]): if attempt < max_retries: - print(f"šŸ”„ Authentication error detected, refreshing credentials and retrying... (attempt {attempt + 1}/{max_retries + 1})", file=sys.stderr) + logger.info(f"Authentication error detected, refreshing credentials and retrying (attempt {attempt + 1}/{max_retries + 1})") invalidate_cached_credentials() continue else: - print(f"āŒ Authentication failed after {max_retries + 1} attempts: {e}", file=sys.stderr) + 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 @@ -121,197 +112,33 @@ async def retry_on_auth_error(func: Callable[[], Awaitable[T]], max_retries: int raise last_exception -def _update_config_file(config_path: str, new_access_token: str, expires_at: int): - """Update the config file with new access token and expiry.""" - 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: - # Log error but don't fail - let the caller handle it - print(f"Warning: Failed to update config file: {e}", file=sys.stderr) - - -def _create_credentials() -> google.auth.credentials.Credentials: - """Returns Google Analytics API credentials. - - Supports two authentication methods: - 1. OAuth2 with access/refresh tokens from config file (preferred) - 2. Application Default Credentials (fallback) - - The config file format for OAuth2: - { - "googleOAuthCredentials": { - "clientId": "YOUR_CLIENT_ID", - "clientSecret": "YOUR_CLIENT_SECRET" - }, - "googleAnalyticsTokens": { - "accessToken": "YOUR_ACCESS_TOKEN", - "refreshToken": "YOUR_REFRESH_TOKEN", - "expiresAt": UNIX_TIMESTAMP - } - } - """ - global _cached_credentials - - # Return cached credentials if still valid - if _cached_credentials and not _cached_credentials.expired: - logger.debug("Using cached credentials (not expired)") - return _cached_credentials - elif _cached_credentials: - logger.info("Cached credentials expired, recreating...") - - # Try to get config path from coordinator or environment - config_path = _get_config_path() - logger.debug(f"Config path: {config_path}") - - if config_path: - # Attempt OAuth2 authentication from config file - credentials = _try_oauth_authentication(config_path) - if credentials: - _cached_credentials = credentials - return credentials - - # Fallback to Application Default Credentials - print("Using Application Default Credentials", file=sys.stderr) - (credentials, _) = google.auth.default(scopes=[_READ_ONLY_ANALYTICS_SCOPE]) - _cached_credentials = credentials - return credentials - - def _get_config_path() -> str: - """Get the config file path from coordinator or environment.""" + """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: - # Fallback to environment variable if coordinator not initialized - return os.environ.get('GOOGLE_ANALYTICS_CONFIG_PATH') - - -def _try_oauth_authentication(config_path: str) -> google.auth.credentials.Credentials: - """Try to authenticate using OAuth2 credentials from config file. - - 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) - logger.debug("Successfully loaded config file") - - oauth_config = config.get('googleOAuthCredentials') - tokens = config.get('googleAnalyticsTokens') - - # Check if we have OAuth configuration - if not oauth_config or not tokens: - 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]): - print("OAuth config incomplete, missing required fields", file=sys.stderr) - return None - - # Convert expiresAt timestamp to datetime if available - # Note: Use naive datetime (no timezone) to match what google.auth.credentials expects - expires_at = tokens.get('expiresAt') - expiry = None - if expires_at: - expiry = datetime.utcfromtimestamp(expires_at) - - 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 - ) - - # Refresh token if expired or no expiry info - if not expires_at or credentials.expired: - logger.info(f"Token expired or missing expiry, refreshing... (expired={credentials.expired})") - try: - 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})") - except Exception as e: - logger.error(f"Failed to refresh token: {e}") - import traceback - traceback.print_exc() - return None - else: - logger.debug(f"Using cached token (expires at {expires_at})") - - return credentials - - except (FileNotFoundError, json.JSONDecodeError) as e: - print(f"Could not load OAuth config from file: {e}", file=sys.stderr) - return None - except PermissionError as e: - print(f"Permission denied accessing config file: {config_path}", file=sys.stderr) - print(f"Error: {e}", file=sys.stderr) - print("", file=sys.stderr) - print("SOLUTION: Move your config file to an accessible location:", file=sys.stderr) - print(f" cp '{config_path}' '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/'", file=sys.stderr) - print("Then run the MCP server with the new path:", file=sys.stderr) - print(f" python run_mcp_server.py '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/{os.path.basename(config_path)}'", file=sys.stderr) - print("", file=sys.stderr) - print("Alternatively, grant your Terminal app access to the Desktop folder:", file=sys.stderr) - print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable Desktop", file=sys.stderr) - return None - except Exception as e: - error_msg = str(e).lower() - if "operation not permitted" in error_msg or "permission denied" in error_msg: - print(f"Permission error accessing config file: {config_path}", file=sys.stderr) - print(f"Error: {e}", file=sys.stderr) - print("", file=sys.stderr) - print("SOLUTION: Move your config file to an accessible location:", file=sys.stderr) - print(f" cp '{config_path}' '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/'", file=sys.stderr) - print("Then run the MCP server with the new path:", file=sys.stderr) - print(f" python run_mcp_server.py '/Users/{os.environ.get('USER', 'your-username')}/projects/google-analytics-mcp/{os.path.basename(config_path)}'", file=sys.stderr) - print("", file=sys.stderr) - print("Alternatively, grant your Terminal app access to the Desktop folder:", file=sys.stderr) - print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable Desktop", file=sys.stderr) - else: - print(f"Unexpected error during OAuth authentication: {e}", file=sys.stderr) 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. - """ + """Returns a properly configured Google Analytics Admin API async client.""" global _cached_admin_client + # Get config path from coordinator + config_path = _get_config_path() + # Return cached client if credentials are still valid if _cached_admin_client: - creds = _create_credentials() + creds = create_credentials(config_path) if not creds.expired: logger.debug("Reusing cached Admin API client") return _cached_admin_client logger.debug("Creating new Admin API client") - creds = _create_credentials() + creds = create_credentials(config_path) _cached_admin_client = admin_v1beta.AnalyticsAdminServiceAsyncClient( client_info=_CLIENT_INFO, credentials=creds ) @@ -319,21 +146,21 @@ def create_admin_api_client() -> admin_v1beta.AnalyticsAdminServiceAsyncClient: 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. - """ + """Returns a properly configured Google Analytics Data API async client.""" global _cached_data_client + # Get config path from coordinator + config_path = _get_config_path() + # Return cached client if credentials are still valid if _cached_data_client: - creds = _create_credentials() + creds = create_credentials(config_path) if not creds.expired: logger.debug("Reusing cached Data API client") return _cached_data_client logger.debug("Creating new Data API client") - creds = _create_credentials() + creds = create_credentials(config_path) _cached_data_client = data_v1beta.BetaAnalyticsDataAsyncClient( client_info=_CLIENT_INFO, credentials=creds ) diff --git a/run_mcp_server.py b/run_mcp_server.py index 5079cce..5c68c5d 100755 --- a/run_mcp_server.py +++ b/run_mcp_server.py @@ -3,86 +3,72 @@ import sys import os +import logging # Set up paths project_root = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, project_root) +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S.%f' +) + +logger = logging.getLogger(__name__) + + +def _print_permission_error_help(config_path: str): + """Print helpful error message for permission issues.""" + print(f"\nāŒ ERROR: Permission denied accessing config file: {config_path}", file=sys.stderr) + print("āŒ This usually happens when the file is on Desktop/Documents and Terminal lacks folder access.", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ”§ SOLUTION 1 (Recommended): Move your config file to an accessible location:", file=sys.stderr) + print(f" cp '{config_path}' '{project_root}/'", file=sys.stderr) + print(" Then run the MCP server with the new path:", file=sys.stderr) + print(f" python {sys.argv[0]} '{project_root}/{os.path.basename(config_path)}'", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ”§ SOLUTION 2: Grant Terminal app access to the folder containing your config:", file=sys.stderr) + print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable folder access", file=sys.stderr) + print(" (Note: You may need to restart Terminal after granting permissions)", file=sys.stderr) + print("", file=sys.stderr) + print("šŸ’” TIP: For MCP servers, it's best practice to keep config files in the project directory.", file=sys.stderr) + + try: - # Import and run the server from analytics_mcp.server import run_server - + # Config path is optional - if provided, must exist and be accessible config_path = None if len(sys.argv) > 1: config_path = sys.argv[1] if not os.path.exists(config_path): - print(f"Error: Config file not found: {config_path}", file=sys.stderr) + print(f"āŒ Config file not found: {config_path}", file=sys.stderr) print(f"Usage: {sys.argv[0]} [config_path]", file=sys.stderr) print(f" config_path: Optional OAuth config file (uses ADC if not provided)", file=sys.stderr) sys.exit(1) - # Test file accessibility early to provide helpful error messages + # Test file accessibility early try: with open(config_path, 'r') as f: - f.read(1) # Try to read at least 1 byte + f.read(1) except PermissionError: - print(f"\nāŒ ERROR: Permission denied accessing config file: {config_path}", file=sys.stderr) - print(f"āŒ This usually happens when the config file is on Desktop/Documents and Terminal lacks folder access.", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ”§ SOLUTION 1 (Recommended): Move your config file to an accessible location:", file=sys.stderr) - print(f" cp '{config_path}' '{project_root}/'", file=sys.stderr) - print(" Then run the MCP server with the new path:", file=sys.stderr) - print(f" python {sys.argv[0]} '{project_root}/{os.path.basename(config_path)}'", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ”§ SOLUTION 2: Grant Terminal app access to the folder containing your config:", file=sys.stderr) - print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable folder access", file=sys.stderr) - print(" (Note: You may need to restart Terminal after granting permissions)", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ’” TIP: For MCP servers, it's best practice to keep config files in the project directory.", file=sys.stderr) + _print_permission_error_help(config_path) sys.exit(1) except Exception as e: error_msg = str(e).lower() if "operation not permitted" in error_msg or "permission denied" in error_msg: - print(f"\nāŒ ERROR: Permission error accessing config file: {config_path}", file=sys.stderr) - print(f"āŒ Details: {e}", file=sys.stderr) - print(f"āŒ This usually happens when the config file is on Desktop/Documents and Terminal lacks folder access.", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ”§ SOLUTION 1 (Recommended): Move your config file to an accessible location:", file=sys.stderr) - print(f" cp '{config_path}' '{project_root}/'", file=sys.stderr) - print(" Then run the MCP server with the new path:", file=sys.stderr) - print(f" python {sys.argv[0]} '{project_root}/{os.path.basename(config_path)}'", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ”§ SOLUTION 2: Grant Terminal app access to the folder containing your config:", file=sys.stderr) - print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable folder access", file=sys.stderr) - print(" (Note: You may need to restart Terminal after granting permissions)", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ’” TIP: For MCP servers, it's best practice to keep config files in the project directory.", file=sys.stderr) + _print_permission_error_help(config_path) sys.exit(1) + raise - print(f"āœ… Using OAuth config file: {config_path}", file=sys.stderr) + logger.info(f"Using OAuth config file: {config_path}") else: - print(f"āœ… Starting MCP server with Application Default Credentials", file=sys.stderr) - + logger.info("Starting MCP server with Application Default Credentials") + run_server(config_path) - + except Exception as e: - error_msg = str(e).lower() - if "cannot access config file" in error_msg and ("permission" in error_msg or "operation not permitted" in error_msg): - print(f"\nāŒ Server failed to start: {e}", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ”§ This is likely a file permission issue. Please try one of these solutions:", file=sys.stderr) - print(" 1. Move your config file to the project directory", file=sys.stderr) - print(" 2. Grant Terminal app access to the folder containing your config file", file=sys.stderr) - print(" 3. Run the server from the folder containing your config file", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ’” For detailed instructions, run the server again to see the specific error message.", file=sys.stderr) - else: - print(f"\nāŒ Server crashed with error: {e}", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ” Full error details:", file=sys.stderr) - import traceback - traceback.print_exc(file=sys.stderr) - print("", file=sys.stderr) - print("šŸ’” If this persists, check the server logs or file an issue on GitHub.", file=sys.stderr) + logger.error(f"Server crashed: {e}", exc_info=True) sys.exit(1) \ No newline at end of file From 9da1155c4d911981d717dc1355c4f3e11b481746 Mon Sep 17 00:00:00 2001 From: Menelaos Kotsollaris Date: Wed, 1 Oct 2025 11:47:53 -0400 Subject: [PATCH 7/8] manage stale connections --- analytics_mcp/auth.py | 51 +++++++++++++++++---------- analytics_mcp/tools/utils.py | 68 ++++++++++++++++++++++++------------ 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/analytics_mcp/auth.py b/analytics_mcp/auth.py index 9b5b4c1..66fb457 100644 --- a/analytics_mcp/auth.py +++ b/analytics_mcp/auth.py @@ -44,25 +44,28 @@ def invalidate_cache(): _cached_credentials = None -def create_credentials(config_path: Optional[str] = None) -> google.auth.credentials.Credentials: +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 - if _cached_credentials and not _cached_credentials.expired: + # 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: + 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: @@ -125,23 +128,33 @@ def _try_oauth_authentication(config_path: str) -> Optional[Credentials]: expiry=expiry ) - # Refresh token if expired or no expiry info - if not expires_at or credentials.expired: - logger.info(f"Token expired or missing expiry, refreshing (expired={credentials.expired})") + # 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: - 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})") + # 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.error(f"Failed to refresh token: {e}", exc_info=True) - return None - else: - logger.debug(f"Using cached token (expires at {expires_at})") + 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 diff --git a/analytics_mcp/tools/utils.py b/analytics_mcp/tools/utils.py index 21dac54..002c80a 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -54,9 +54,9 @@ def _get_package_version_with_fallback(): def invalidate_cached_credentials(): - """Invalidate cached credentials to force refresh on next request.""" + """Invalidate cached credentials and clients to force refresh on next request.""" global _cached_admin_client, _cached_data_client - logger.info("Invalidating cached API clients") + logger.info("Invalidating cached API clients and credentials") invalidate_auth_cache() _cached_admin_client = None _cached_data_client = None @@ -86,6 +86,7 @@ async def retry_on_auth_error(func: Callable[[], Awaitable[T]], max_retries: int 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 [ @@ -99,12 +100,13 @@ async def retry_on_auth_error(func: Callable[[], Awaitable[T]], max_retries: int 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) + 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 @@ -124,46 +126,66 @@ def _get_config_path() -> str: def create_admin_api_client() -> admin_v1beta.AnalyticsAdminServiceAsyncClient: - """Returns a properly configured Google Analytics Admin API async client.""" + """Returns a properly configured Google Analytics Admin API async client. + + Automatically handles credential refresh and caching. + """ global _cached_admin_client # Get config path from coordinator config_path = _get_config_path() - # Return cached client if credentials are still valid - if _cached_admin_client: + # 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) - if not creds.expired: - logger.debug("Reusing cached Admin API client") - return _cached_admin_client + _cached_admin_client = admin_v1beta.AnalyticsAdminServiceAsyncClient( + client_info=_CLIENT_INFO, credentials=creds + ) + return _cached_admin_client - logger.debug("Creating new Admin API client") + # Check if cached client's credentials are still valid creds = create_credentials(config_path) - _cached_admin_client = admin_v1beta.AnalyticsAdminServiceAsyncClient( - client_info=_CLIENT_INFO, credentials=creds - ) + 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.""" + """Returns a properly configured Google Analytics Data API async client. + + Automatically handles credential refresh and caching. + """ global _cached_data_client # Get config path from coordinator config_path = _get_config_path() - # Return cached client if credentials are still valid - if _cached_data_client: + # 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) - if not creds.expired: - logger.debug("Reusing cached Data API client") - return _cached_data_client + _cached_data_client = data_v1beta.BetaAnalyticsDataAsyncClient( + client_info=_CLIENT_INFO, credentials=creds + ) + return _cached_data_client - logger.debug("Creating new Data API client") + # Check if cached client's credentials are still valid creds = create_credentials(config_path) - _cached_data_client = data_v1beta.BetaAnalyticsDataAsyncClient( - client_info=_CLIENT_INFO, credentials=creds - ) + 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 From 79cc8ef7b8f156ba0348ad1b494848f671f3df60 Mon Sep 17 00:00:00 2001 From: Menelaos Kotsollaris Date: Wed, 1 Oct 2025 12:23:53 -0400 Subject: [PATCH 8/8] cleanup structure --- refresh_and_update_config.py | 82 ++++++++++++++++++++---------------- refresh_token.py | 60 -------------------------- run_mcp_server.py | 74 -------------------------------- run_server.sh | 33 --------------- 4 files changed, 46 insertions(+), 203 deletions(-) delete mode 100644 refresh_token.py delete mode 100755 run_mcp_server.py delete mode 100755 run_server.sh diff --git a/refresh_and_update_config.py b/refresh_and_update_config.py index dcb3876..1117d0f 100644 --- a/refresh_and_update_config.py +++ b/refresh_and_update_config.py @@ -1,28 +1,40 @@ #!/usr/bin/env python3 -"""Script to refresh Google OAuth token and update config with expiry.""" +"""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 -import time from google.oauth2.credentials import Credentials from google.auth.transport.requests import Request -def refresh_and_update_config(config_path=None): - """Refresh the Google OAuth access token and update config.""" - if not config_path: - config_path = '/Users/mkotsollaris/Projects/google-analytics-config.json' - + +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', {}) - - print("šŸ”„ Refreshing OAuth token...") - print(f"Current access token: {tokens.get('accessToken', 'None')[:50]}...") - + + 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'), @@ -32,39 +44,37 @@ def refresh_and_update_config(config_path=None): client_secret=oauth_config.get('clientSecret'), scopes=['https://www.googleapis.com/auth/analytics.readonly'] ) - - # Force refresh the token - print("šŸ”„ Refreshing token...") + + # Refresh the token credentials.refresh(Request()) - - # Calculate expiry time (1 hour from now - typical OAuth token expiry) - expires_at = int(time.time()) + 3600 - + # Update config with new token and expiry config['googleAnalyticsTokens']['accessToken'] = credentials.token - config['googleAnalyticsTokens']['expiresAt'] = expires_at - + 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("āœ… Token refreshed successfully!") - print(f"New access token: {credentials.token[:50]}...") - print(f"Expires at: {expires_at} ({time.ctime(expires_at)})") - print("Config file updated.") - + + 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}") - import traceback - traceback.print_exc() + print(f"āŒ Error refreshing token: {e}", file=sys.stderr) return False + if __name__ == "__main__": - config_path = sys.argv[1] if len(sys.argv) > 1 else None + 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) - if success: - print("\nšŸŽ‰ Token refresh complete! You can now restart your MCP server.") - else: - print("\nšŸ’„ Token refresh failed. Check the error messages above.") \ No newline at end of file + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/refresh_token.py b/refresh_token.py deleted file mode 100644 index a7bd48d..0000000 --- a/refresh_token.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -"""Script to manually refresh Google OAuth access token.""" - -import json -from google.oauth2.credentials import Credentials -from google.auth.transport.requests import Request - -def refresh_token(): - """Refresh the Google OAuth access token.""" - try: - # Load current config - config_path = 'google-analytics-config (4).json' - with open(config_path, 'r') as f: - config = json.load(f) - - oauth_config = config.get('googleOAuthCredentials', {}) - tokens = config.get('googleAnalyticsTokens', {}) - - print("Current tokens:") - print(f" Access token: {tokens.get('accessToken', 'None')[:50]}...") - print(f" Refresh token: {tokens.get('refreshToken', 'None')[:50]}...") - print(f" Client ID: {oauth_config.get('clientId', 'None')[:50]}...") - - # 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'] - ) - - print(f"\nToken expired: {credentials.expired}") - - if credentials.refresh_token: - print("Attempting to refresh token...") - credentials.refresh(Request()) - - # Update config with new token - config['googleAnalyticsTokens']['accessToken'] = credentials.token - - # Save updated config - with open(config_path, 'w') as f: - json.dump(config, f, indent=2) - - print("āœ… Token refreshed successfully!") - print(f"New access token: {credentials.token[:50]}...") - print("Config file updated.") - - else: - print("āŒ No refresh token available. Need to re-authenticate.") - - except Exception as e: - print(f"āŒ Error refreshing token: {e}") - import traceback - traceback.print_exc() - -if __name__ == "__main__": - refresh_token() diff --git a/run_mcp_server.py b/run_mcp_server.py deleted file mode 100755 index 5c68c5d..0000000 --- a/run_mcp_server.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -"""Wrapper to run the MCP server with proper environment.""" - -import sys -import os -import logging - -# Set up paths -project_root = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, project_root) - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s [%(levelname)s] %(message)s', - datefmt='%Y-%m-%d %H:%M:%S.%f' -) - -logger = logging.getLogger(__name__) - - -def _print_permission_error_help(config_path: str): - """Print helpful error message for permission issues.""" - print(f"\nāŒ ERROR: Permission denied accessing config file: {config_path}", file=sys.stderr) - print("āŒ This usually happens when the file is on Desktop/Documents and Terminal lacks folder access.", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ”§ SOLUTION 1 (Recommended): Move your config file to an accessible location:", file=sys.stderr) - print(f" cp '{config_path}' '{project_root}/'", file=sys.stderr) - print(" Then run the MCP server with the new path:", file=sys.stderr) - print(f" python {sys.argv[0]} '{project_root}/{os.path.basename(config_path)}'", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ”§ SOLUTION 2: Grant Terminal app access to the folder containing your config:", file=sys.stderr) - print(" System Settings → Privacy & Security → Files and Folders → Terminal → Enable folder access", file=sys.stderr) - print(" (Note: You may need to restart Terminal after granting permissions)", file=sys.stderr) - print("", file=sys.stderr) - print("šŸ’” TIP: For MCP servers, it's best practice to keep config files in the project directory.", file=sys.stderr) - - -try: - from analytics_mcp.server import run_server - - # Config path is optional - if provided, must exist and be accessible - config_path = None - if len(sys.argv) > 1: - config_path = sys.argv[1] - if not os.path.exists(config_path): - print(f"āŒ Config file not found: {config_path}", file=sys.stderr) - print(f"Usage: {sys.argv[0]} [config_path]", file=sys.stderr) - print(f" config_path: Optional OAuth config file (uses ADC if not provided)", file=sys.stderr) - sys.exit(1) - - # Test file accessibility early - try: - with open(config_path, 'r') as f: - f.read(1) - except PermissionError: - _print_permission_error_help(config_path) - sys.exit(1) - except Exception as e: - error_msg = str(e).lower() - if "operation not permitted" in error_msg or "permission denied" in error_msg: - _print_permission_error_help(config_path) - sys.exit(1) - raise - - logger.info(f"Using OAuth config file: {config_path}") - else: - logger.info("Starting MCP server with Application Default Credentials") - - run_server(config_path) - -except Exception as e: - logger.error(f"Server crashed: {e}", exc_info=True) - sys.exit(1) \ No newline at end of file diff --git a/run_server.sh b/run_server.sh deleted file mode 100755 index 887431c..0000000 --- a/run_server.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -# Set the project root directory -PROJECT_ROOT="$(dirname "$0")" - -# Check if config path is provided as argument -if [ $# -eq 0 ]; then - echo "Error: Config file path is required" >&2 - echo "Usage: $0 " >&2 - exit 1 -fi - -CONFIG_PATH="$1" - -# Check if config file exists -if [ ! -f "$CONFIG_PATH" ]; then - echo "Error: Config file not found: $CONFIG_PATH" >&2 - exit 1 -fi - -# Add the project root to PYTHONPATH -export PYTHONPATH="$PROJECT_ROOT:$PYTHONPATH" - -# Install dependencies if needed (only once) -if ! /opt/homebrew/bin/python3 -c "import mcp" 2>/dev/null; then - echo "Installing dependencies..." >&2 - /opt/homebrew/bin/python3 -m pip install --user google-analytics-admin google-analytics-data google-auth mcp httpx -fi - -echo "Starting MCP server with config: $CONFIG_PATH" >&2 - -# Run the server with config path -exec /opt/homebrew/bin/python3 "$PROJECT_ROOT/run_mcp_server.py" "$CONFIG_PATH" \ No newline at end of file