From 335a9603e805881670d44b95eefda2da4993a0c3 Mon Sep 17 00:00:00 2001 From: Zhang <62634883+Ttian18@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:41:08 -0800 Subject: [PATCH] feat(tools): add Google Analytics 4 integration (#3727) Add read-only GA4 Data API v1 tools: ga_run_report, ga_get_realtime, ga_get_top_pages, and ga_get_traffic_sources. Includes credential spec, unit tests, and README. --- tools/pyproject.toml | 1 + tools/src/aden_tools/credentials/__init__.py | 4 + .../credentials/google_analytics.py | 40 + tools/src/aden_tools/tools/__init__.py | 2 + .../tools/google_analytics_tool/README.md | 124 +++ .../tools/google_analytics_tool/__init__.py | 5 + .../google_analytics_tool.py | 344 ++++++++ tools/tests/credentials/__init__.py | 1 + .../test_google_analytics_credentials.py | 53 ++ .../tests/tools/test_google_analytics_tool.py | 744 ++++++++++++++++++ uv.lock | 18 + 11 files changed, 1336 insertions(+) create mode 100644 tools/src/aden_tools/credentials/google_analytics.py create mode 100644 tools/src/aden_tools/tools/google_analytics_tool/README.md create mode 100644 tools/src/aden_tools/tools/google_analytics_tool/__init__.py create mode 100644 tools/src/aden_tools/tools/google_analytics_tool/google_analytics_tool.py create mode 100644 tools/tests/credentials/__init__.py create mode 100644 tools/tests/credentials/test_google_analytics_credentials.py create mode 100644 tools/tests/tools/test_google_analytics_tool.py diff --git a/tools/pyproject.toml b/tools/pyproject.toml index e533eceb..62a16839 100644 --- a/tools/pyproject.toml +++ b/tools/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "litellm>=1.81.0", "dnspython>=2.4.0", "resend>=2.0.0", + "google-analytics-data>=0.18.0", "framework", "stripe>=14.3.0", "arxiv>=2.1.0", diff --git a/tools/src/aden_tools/credentials/__init__.py b/tools/src/aden_tools/credentials/__init__.py index 5945ba76..ac96004d 100644 --- a/tools/src/aden_tools/credentials/__init__.py +++ b/tools/src/aden_tools/credentials/__init__.py @@ -41,6 +41,7 @@ Credential categories: - github.py: GitHub API credentials - hubspot.py: HubSpot CRM credentials - slack.py: Slack workspace credentials +- google_analytics.py: Google Analytics credentials - google_maps.py: Google Maps Platform credentials - calcom.py: Cal.com scheduling API credentials @@ -63,6 +64,7 @@ from .discord import DISCORD_CREDENTIALS from .email import EMAIL_CREDENTIALS from .gcp_vision import GCP_VISION_CREDENTIALS from .github import GITHUB_CREDENTIALS +from .google_analytics import GOOGLE_ANALYTICS_CREDENTIALS from .google_calendar import GOOGLE_CALENDAR_CREDENTIALS from .google_docs import GOOGLE_DOCS_CREDENTIALS from .google_maps import GOOGLE_MAPS_CREDENTIALS @@ -100,6 +102,7 @@ CREDENTIAL_SPECS = { **APOLLO_CREDENTIALS, **DISCORD_CREDENTIALS, **GITHUB_CREDENTIALS, + **GOOGLE_ANALYTICS_CREDENTIALS, **GOOGLE_DOCS_CREDENTIALS, **GOOGLE_MAPS_CREDENTIALS, **HUBSPOT_CREDENTIALS, @@ -145,6 +148,7 @@ __all__ = [ "EMAIL_CREDENTIALS", "GCP_VISION_CREDENTIALS", "GITHUB_CREDENTIALS", + "GOOGLE_ANALYTICS_CREDENTIALS", "GOOGLE_DOCS_CREDENTIALS", "GOOGLE_MAPS_CREDENTIALS", "HUBSPOT_CREDENTIALS", diff --git a/tools/src/aden_tools/credentials/google_analytics.py b/tools/src/aden_tools/credentials/google_analytics.py new file mode 100644 index 00000000..109dd22a --- /dev/null +++ b/tools/src/aden_tools/credentials/google_analytics.py @@ -0,0 +1,40 @@ +""" +Google Analytics credentials. + +Contains credentials for Google Analytics 4 Data API integration. +""" + +from .base import CredentialSpec + +GOOGLE_ANALYTICS_CREDENTIALS = { + "google_analytics": CredentialSpec( + env_var="GOOGLE_APPLICATION_CREDENTIALS", + tools=[ + "ga_run_report", + "ga_get_realtime", + "ga_get_top_pages", + "ga_get_traffic_sources", + ], + required=True, + startup_required=False, + help_url="https://developers.google.com/analytics/devguides/reporting/data/v1/quickstart-client-libraries", + description="Path to Google Cloud service account JSON key with Analytics read access", + # Auth method support + aden_supported=False, + direct_api_key_supported=True, + api_key_instructions="""To set up Google Analytics credentials: +1. Go to Google Cloud Console > IAM & Admin > Service Accounts +2. Create a service account (e.g., "hive-analytics-reader") +3. Download the JSON key file +4. In Google Analytics, go to Admin > Property > Property Access Management +5. Add the service account email with "Viewer" role +6. Set the env var to the path of the JSON key file: + export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json""", + # Health check - GA4 Data API doesn't have a simple health endpoint + health_check_endpoint="", + health_check_method="GET", + # Credential store mapping + credential_id="google_analytics", + credential_key="service_account_key_path", + ), +} diff --git a/tools/src/aden_tools/tools/__init__.py b/tools/src/aden_tools/tools/__init__.py index edc3c464..bc9ba1cf 100644 --- a/tools/src/aden_tools/tools/__init__.py +++ b/tools/src/aden_tools/tools/__init__.py @@ -54,6 +54,7 @@ from .file_system_toolkits.view_file import register_tools as register_view_file from .file_system_toolkits.write_to_file import register_tools as register_write_to_file from .github_tool import register_tools as register_github from .gmail_tool import register_tools as register_gmail +from .google_analytics_tool import register_tools as register_google_analytics from .google_docs_tool import register_tools as register_google_docs from .google_maps_tool import register_tools as register_google_maps from .http_headers_scanner import register_tools as register_http_headers_scanner @@ -126,6 +127,7 @@ def register_all_tools( register_slack(mcp, credentials=credentials) register_telegram(mcp, credentials=credentials) register_vision(mcp, credentials=credentials) + register_google_analytics(mcp, credentials=credentials) register_google_docs(mcp, credentials=credentials) register_google_maps(mcp, credentials=credentials) register_account_info(mcp, credentials=credentials) diff --git a/tools/src/aden_tools/tools/google_analytics_tool/README.md b/tools/src/aden_tools/tools/google_analytics_tool/README.md new file mode 100644 index 00000000..81b839bb --- /dev/null +++ b/tools/src/aden_tools/tools/google_analytics_tool/README.md @@ -0,0 +1,124 @@ +# Google Analytics Tool + +Query GA4 website traffic and marketing performance data via the Data API v1. + +## Description + +Provides read-only access to Google Analytics 4 (GA4) properties. Use these tools to pull website traffic data, monitor real-time activity, and analyze marketing performance. + +Supports: +- **Custom reports** with any combination of GA4 dimensions and metrics +- **Real-time data** for current website activity +- **Convenience wrappers** for common queries (top pages, traffic sources) + +## Tools + +### `ga_run_report` + +Run a custom GA4 report with flexible dimensions, metrics, and date ranges. + +| Argument | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `property_id` | str | Yes | - | GA4 property ID (e.g., `"properties/123456"`) | +| `metrics` | list[str] | Yes | - | Metrics to retrieve (e.g., `["sessions", "totalUsers"]`) | +| `dimensions` | list[str] | No | `None` | Dimensions to group by (e.g., `["pagePath", "sessionSource"]`) | +| `start_date` | str | No | `"28daysAgo"` | Start date (e.g., `"2024-01-01"` or `"7daysAgo"`) | +| `end_date` | str | No | `"today"` | End date | +| `limit` | int | No | `100` | Max rows to return (1-10000) | + +### `ga_get_realtime` + +Get real-time analytics data (active users, current pages). + +| Argument | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `property_id` | str | Yes | - | GA4 property ID | +| `metrics` | list[str] | No | `["activeUsers"]` | Metrics to retrieve | + +### `ga_get_top_pages` + +Get top pages by views and engagement (convenience wrapper). + +| Argument | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `property_id` | str | Yes | - | GA4 property ID | +| `start_date` | str | No | `"28daysAgo"` | Start date | +| `end_date` | str | No | `"today"` | End date | +| `limit` | int | No | `10` | Max pages to return (1-10000) | + +Returns: `pagePath`, `pageTitle`, `screenPageViews`, `averageSessionDuration`, `bounceRate` + +### `ga_get_traffic_sources` + +Get traffic breakdown by source/medium (convenience wrapper). + +| Argument | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `property_id` | str | Yes | - | GA4 property ID | +| `start_date` | str | No | `"28daysAgo"` | Start date | +| `end_date` | str | No | `"today"` | End date | +| `limit` | int | No | `10` | Max sources to return (1-10000) | + +Returns: `sessionSource`, `sessionMedium`, `sessions`, `totalUsers`, `conversions` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `GOOGLE_APPLICATION_CREDENTIALS` | Yes | Path to Google Cloud service account JSON key file | + +## Setup + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) > IAM & Admin > Service Accounts +2. Create a service account (e.g., "hive-analytics-reader") +3. Download the JSON key file +4. Enable the **Google Analytics Data API** in your Google Cloud project +5. In Google Analytics, go to Admin > Property > Property Access Management +6. Add the service account email with **Viewer** role +7. Set the environment variable: + ```bash + export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json + ``` + +## Common GA4 Metrics + +`sessions`, `totalUsers`, `newUsers`, `screenPageViews`, `conversions`, `bounceRate`, `averageSessionDuration`, `engagedSessions` + +## Common GA4 Dimensions + +`pagePath`, `pageTitle`, `sessionSource`, `sessionMedium`, `country`, `deviceCategory`, `date` + +## Example Usage + +```python +# Custom report: sessions by page over the last 7 days +result = ga_run_report( + property_id="properties/123456", + metrics=["sessions", "screenPageViews"], + dimensions=["pagePath"], + start_date="7daysAgo", +) + +# Real-time active users +result = ga_get_realtime(property_id="properties/123456") + +# Top 10 pages this month +result = ga_get_top_pages( + property_id="properties/123456", + start_date="2024-01-01", + end_date="2024-01-31", +) + +# Traffic sources breakdown +result = ga_get_traffic_sources(property_id="properties/123456") +``` + +## Error Handling + +Returns error dicts for common issues: +- `Google Analytics credentials not configured` - No credentials set +- `property_id must start with 'properties/'` - Invalid property ID format +- `metrics list must not be empty` - No metrics provided +- `limit must be between 1 and 10000` - Limit out of bounds +- `Failed to initialize Google Analytics client` - Bad credentials file +- `Google Analytics API error: ...` - API-level errors (permissions, quota, etc.) diff --git a/tools/src/aden_tools/tools/google_analytics_tool/__init__.py b/tools/src/aden_tools/tools/google_analytics_tool/__init__.py new file mode 100644 index 00000000..fbf34480 --- /dev/null +++ b/tools/src/aden_tools/tools/google_analytics_tool/__init__.py @@ -0,0 +1,5 @@ +"""Google Analytics Tool - Query GA4 website traffic and marketing data.""" + +from .google_analytics_tool import register_tools + +__all__ = ["register_tools"] diff --git a/tools/src/aden_tools/tools/google_analytics_tool/google_analytics_tool.py b/tools/src/aden_tools/tools/google_analytics_tool/google_analytics_tool.py new file mode 100644 index 00000000..cf353f24 --- /dev/null +++ b/tools/src/aden_tools/tools/google_analytics_tool/google_analytics_tool.py @@ -0,0 +1,344 @@ +""" +Google Analytics Tool - Query GA4 website traffic and marketing performance data. + +Provides read-only access to Google Analytics 4 via the Data API v1. + +Supports: +- Service account authentication (GOOGLE_APPLICATION_CREDENTIALS) +- Credential store via CredentialStoreAdapter + +API Reference: https://developers.google.com/analytics/devguides/reporting/data/v1 +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING, Any + +from fastmcp import FastMCP +from google.analytics.data_v1beta import BetaAnalyticsDataClient +from google.analytics.data_v1beta.types import ( + DateRange, + Dimension, + Metric, + MinuteRange, + RunRealtimeReportRequest, + RunReportRequest, +) + +if TYPE_CHECKING: + from aden_tools.credentials import CredentialStoreAdapter + +logger = logging.getLogger(__name__) + + +class _GAClient: + """Internal client wrapping Google Analytics 4 Data API v1beta calls.""" + + def __init__(self, credentials_path: str): + self._credentials_path = credentials_path + # The GA4 client reads GOOGLE_APPLICATION_CREDENTIALS from the environment + # We set it here so the client picks up the correct path + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = credentials_path + self._client = BetaAnalyticsDataClient() + + def run_report( + self, + property_id: str, + metrics: list[str], + dimensions: list[str] | None = None, + start_date: str = "28daysAgo", + end_date: str = "today", + limit: int = 100, + ) -> dict[str, Any]: + """Run a GA4 report and return structured results.""" + request = RunReportRequest( + property=property_id, + metrics=[Metric(name=m) for m in metrics], + dimensions=[Dimension(name=d) for d in (dimensions or [])], + date_ranges=[DateRange(start_date=start_date, end_date=end_date)], + limit=limit, + ) + + response = self._client.run_report(request) + return self._format_report_response(response, dimensions) + + def run_realtime_report( + self, + property_id: str, + metrics: list[str], + ) -> dict[str, Any]: + """Run a GA4 realtime report.""" + request = RunRealtimeReportRequest( + property=property_id, + metrics=[Metric(name=m) for m in metrics], + minute_ranges=[MinuteRange(start_minutes_ago=29, end_minutes_ago=0)], + ) + + response = self._client.run_realtime_report(request) + return self._format_realtime_response(response, metrics) + + def _format_report_response( + self, + response: Any, + dimensions: list[str] | None, + ) -> dict[str, Any]: + """Format a RunReportResponse into a plain dict.""" + rows = [] + dim_headers = [h.name for h in response.dimension_headers] + metric_headers = [h.name for h in response.metric_headers] + + for row in response.rows: + row_data: dict[str, str] = {} + for i, dim_value in enumerate(row.dimension_values): + row_data[dim_headers[i]] = dim_value.value + for i, metric_value in enumerate(row.metric_values): + row_data[metric_headers[i]] = metric_value.value + rows.append(row_data) + + return { + "row_count": response.row_count, + "rows": rows, + "dimension_headers": dim_headers, + "metric_headers": metric_headers, + } + + def _format_realtime_response( + self, + response: Any, + metrics: list[str], + ) -> dict[str, Any]: + """Format a RunRealtimeReportResponse into a plain dict.""" + rows = [] + metric_headers = [h.name for h in response.metric_headers] + + for row in response.rows: + row_data: dict[str, str] = {} + for i, metric_value in enumerate(row.metric_values): + row_data[metric_headers[i]] = metric_value.value + rows.append(row_data) + + return { + "row_count": response.row_count, + "rows": rows, + "metric_headers": metric_headers, + } + + +def register_tools( + mcp: FastMCP, + credentials: CredentialStoreAdapter | None = None, +) -> None: + """Register Google Analytics tools with the MCP server.""" + + def _get_credentials_path() -> str | None: + """Get GA credentials path from credential store or environment.""" + if credentials is not None: + path = credentials.get("google_analytics") + if path is not None and not isinstance(path, str): + raise TypeError( + f"Expected string from credentials.get('google_analytics'), " + f"got {type(path).__name__}" + ) + return path + return os.getenv("GOOGLE_APPLICATION_CREDENTIALS") + + def _get_client() -> _GAClient | dict[str, str]: + """Get a GA client, or return an error dict if no credentials.""" + creds_path = _get_credentials_path() + if not creds_path: + return { + "error": "Google Analytics credentials not configured", + "help": ( + "Set GOOGLE_APPLICATION_CREDENTIALS environment variable " + "to the path of your service account JSON key file, " + "or configure via credential store" + ), + } + try: + return _GAClient(creds_path) + except Exception as e: + return {"error": f"Failed to initialize Google Analytics client: {e}"} + + @mcp.tool() + def ga_run_report( + property_id: str, + metrics: list[str], + dimensions: list[str] | None = None, + start_date: str = "28daysAgo", + end_date: str = "today", + limit: int = 100, + ) -> dict: + """ + Run a custom Google Analytics 4 report. + + Use this tool to query website traffic data with custom dimensions, + metrics, and date ranges. + + Args: + property_id: GA4 property ID (e.g., "properties/123456") + metrics: Metrics to retrieve + (e.g., ["sessions", "totalUsers", "conversions"]) + dimensions: Dimensions to group by + (e.g., ["pagePath", "sessionSource"]) + start_date: Start date (e.g., "2024-01-01" or "28daysAgo") + end_date: End date (e.g., "today") + limit: Max rows to return (1-10000) + + Returns: + Dict with report rows or error + """ + client = _get_client() + if isinstance(client, dict): + return client + + if not property_id or not property_id.startswith("properties/"): + return { + "error": "property_id must start with 'properties/' (e.g., 'properties/123456')" + } + if not metrics: + return {"error": "metrics list must not be empty"} + if limit < 1 or limit > 10000: + return {"error": "limit must be between 1 and 10000"} + + try: + return client.run_report( + property_id=property_id, + metrics=metrics, + dimensions=dimensions, + start_date=start_date, + end_date=end_date, + limit=limit, + ) + except Exception as e: + logger.warning("ga_run_report failed: %s", e) + return {"error": f"Google Analytics API error: {e}"} + + @mcp.tool() + def ga_get_realtime( + property_id: str, + metrics: list[str] | None = None, + ) -> dict: + """ + Get real-time Google Analytics data (active users, current pages). + + Use this tool to check current website activity and detect traffic anomalies. + + Args: + property_id: GA4 property ID (e.g., "properties/123456") + metrics: Metrics to retrieve (default: ["activeUsers"]) + + Returns: + Dict with real-time data or error + """ + client = _get_client() + if isinstance(client, dict): + return client + + if not property_id or not property_id.startswith("properties/"): + return { + "error": "property_id must start with 'properties/' (e.g., 'properties/123456')" + } + + effective_metrics = metrics or ["activeUsers"] + + try: + return client.run_realtime_report( + property_id=property_id, + metrics=effective_metrics, + ) + except Exception as e: + logger.warning("ga_get_realtime failed: %s", e) + return {"error": f"Google Analytics API error: {e}"} + + @mcp.tool() + def ga_get_top_pages( + property_id: str, + start_date: str = "28daysAgo", + end_date: str = "today", + limit: int = 10, + ) -> dict: + """ + Get top pages by views and engagement. + + Convenience wrapper that returns the most-visited pages with + key engagement metrics. + + Args: + property_id: GA4 property ID (e.g., "properties/123456") + start_date: Start date (e.g., "2024-01-01" or "28daysAgo") + end_date: End date (e.g., "today") + limit: Max pages to return (1-10000, default 10) + + Returns: + Dict with top pages, their views, avg engagement time, and bounce rate + """ + client = _get_client() + if isinstance(client, dict): + return client + + if not property_id or not property_id.startswith("properties/"): + return { + "error": "property_id must start with 'properties/' (e.g., 'properties/123456')" + } + if limit < 1 or limit > 10000: + return {"error": "limit must be between 1 and 10000"} + + try: + return client.run_report( + property_id=property_id, + metrics=["screenPageViews", "averageSessionDuration", "bounceRate"], + dimensions=["pagePath", "pageTitle"], + start_date=start_date, + end_date=end_date, + limit=limit, + ) + except Exception as e: + logger.warning("ga_get_top_pages failed: %s", e) + return {"error": f"Google Analytics API error: {e}"} + + @mcp.tool() + def ga_get_traffic_sources( + property_id: str, + start_date: str = "28daysAgo", + end_date: str = "today", + limit: int = 10, + ) -> dict: + """ + Get traffic breakdown by source/medium. + + Convenience wrapper that shows which channels drive visitors to the site. + + Args: + property_id: GA4 property ID (e.g., "properties/123456") + start_date: Start date (e.g., "2024-01-01" or "28daysAgo") + end_date: End date (e.g., "today") + limit: Max sources to return (1-10000, default 10) + + Returns: + Dict with traffic sources, sessions, users, and conversions per source + """ + client = _get_client() + if isinstance(client, dict): + return client + + if not property_id or not property_id.startswith("properties/"): + return { + "error": "property_id must start with 'properties/' (e.g., 'properties/123456')" + } + if limit < 1 or limit > 10000: + return {"error": "limit must be between 1 and 10000"} + + try: + return client.run_report( + property_id=property_id, + metrics=["sessions", "totalUsers", "conversions"], + dimensions=["sessionSource", "sessionMedium"], + start_date=start_date, + end_date=end_date, + limit=limit, + ) + except Exception as e: + logger.warning("ga_get_traffic_sources failed: %s", e) + return {"error": f"Google Analytics API error: {e}"} diff --git a/tools/tests/credentials/__init__.py b/tools/tests/credentials/__init__.py new file mode 100644 index 00000000..0a48b622 --- /dev/null +++ b/tools/tests/credentials/__init__.py @@ -0,0 +1 @@ +"""Credential-specific tests.""" diff --git a/tools/tests/credentials/test_google_analytics_credentials.py b/tools/tests/credentials/test_google_analytics_credentials.py new file mode 100644 index 00000000..919f1e0f --- /dev/null +++ b/tools/tests/credentials/test_google_analytics_credentials.py @@ -0,0 +1,53 @@ +"""Tests for Google Analytics credential spec.""" + +from aden_tools.credentials import CREDENTIAL_SPECS +from aden_tools.credentials.google_analytics import GOOGLE_ANALYTICS_CREDENTIALS + + +class TestGoogleAnalyticsCredentials: + """Tests for the Google Analytics credential specification.""" + + def test_credential_spec_exists(self): + """google_analytics spec exists in the module.""" + assert "google_analytics" in GOOGLE_ANALYTICS_CREDENTIALS + + def test_credential_registered_in_global_specs(self): + """google_analytics spec is merged into CREDENTIAL_SPECS.""" + assert "google_analytics" in CREDENTIAL_SPECS + + def test_env_var(self): + """Spec points to the correct environment variable.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + assert spec.env_var == "GOOGLE_APPLICATION_CREDENTIALS" + + def test_tools_list(self): + """Spec lists all four GA tool names.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + expected = [ + "ga_run_report", + "ga_get_realtime", + "ga_get_top_pages", + "ga_get_traffic_sources", + ] + assert spec.tools == expected + + def test_required_flag(self): + """Credential is required.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + assert spec.required is True + + def test_not_startup_required(self): + """Credential is not required at startup.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + assert spec.startup_required is False + + def test_help_url_set(self): + """Help URL points to GA4 quickstart docs.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + assert "developers.google.com" in spec.help_url + + def test_description_set(self): + """Description is non-empty.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + assert spec.description + assert "service account" in spec.description.lower() diff --git a/tools/tests/tools/test_google_analytics_tool.py b/tools/tests/tools/test_google_analytics_tool.py new file mode 100644 index 00000000..97a62129 --- /dev/null +++ b/tools/tests/tools/test_google_analytics_tool.py @@ -0,0 +1,744 @@ +""" +Tests for Google Analytics tool. + +Covers: +- _GAClient methods (run_report, run_realtime_report, response formatting) +- Credential retrieval (CredentialStoreAdapter vs env var) +- Input validation for all tool functions +- Error handling (no credentials, API errors, timeouts) +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from aden_tools.tools.google_analytics_tool.google_analytics_tool import ( + _GAClient, + register_tools, +) + +# --------------------------------------------------------------------------- +# Helpers to build mock GA4 API responses +# --------------------------------------------------------------------------- + + +def _make_header(name: str) -> MagicMock: + header = MagicMock() + header.name = name + return header + + +def _make_value(value: str) -> MagicMock: + v = MagicMock() + v.value = value + return v + + +def _make_row(dim_values: list[str], metric_values: list[str]) -> MagicMock: + row = MagicMock() + row.dimension_values = [_make_value(v) for v in dim_values] + row.metric_values = [_make_value(v) for v in metric_values] + return row + + +def _make_report_response( + dim_headers: list[str], + metric_headers: list[str], + rows: list[tuple[list[str], list[str]]], + row_count: int | None = None, +) -> MagicMock: + resp = MagicMock() + resp.dimension_headers = [_make_header(h) for h in dim_headers] + resp.metric_headers = [_make_header(h) for h in metric_headers] + resp.rows = [_make_row(dims, metrics) for dims, metrics in rows] + resp.row_count = row_count if row_count is not None else len(rows) + return resp + + +def _make_realtime_response( + metric_headers: list[str], + rows: list[list[str]], + row_count: int | None = None, +) -> MagicMock: + resp = MagicMock() + resp.dimension_headers = [] + resp.metric_headers = [_make_header(h) for h in metric_headers] + resp.rows = [_make_row([], metrics) for metrics in rows] + resp.row_count = row_count if row_count is not None else len(rows) + return resp + + +# --------------------------------------------------------------------------- +# _GAClient tests +# --------------------------------------------------------------------------- + + +class TestGAClient: + """Tests for the internal _GAClient class.""" + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_format_report_response(self, mock_client_cls): + """Report response is formatted into a plain dict.""" + client = _GAClient("/fake/path.json") + + response = _make_report_response( + dim_headers=["pagePath"], + metric_headers=["screenPageViews", "sessions"], + rows=[ + (["/home"], ["1000", "500"]), + (["/about"], ["200", "100"]), + ], + ) + + result = client._format_report_response(response, ["pagePath"]) + + assert result["row_count"] == 2 + assert len(result["rows"]) == 2 + assert result["rows"][0] == { + "pagePath": "/home", + "screenPageViews": "1000", + "sessions": "500", + } + assert result["dimension_headers"] == ["pagePath"] + assert result["metric_headers"] == ["screenPageViews", "sessions"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_format_report_response_no_dimensions(self, mock_client_cls): + """Report with no dimensions still returns valid structure.""" + client = _GAClient("/fake/path.json") + + response = _make_report_response( + dim_headers=[], + metric_headers=["totalUsers"], + rows=[([], ["5000"])], + ) + + result = client._format_report_response(response, None) + + assert result["row_count"] == 1 + assert result["rows"][0] == {"totalUsers": "5000"} + assert result["dimension_headers"] == [] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_format_realtime_response(self, mock_client_cls): + """Realtime response is formatted correctly.""" + client = _GAClient("/fake/path.json") + + response = _make_realtime_response( + metric_headers=["activeUsers"], + rows=[["42"]], + ) + + result = client._format_realtime_response(response, ["activeUsers"]) + + assert result["row_count"] == 1 + assert result["rows"][0] == {"activeUsers": "42"} + assert result["metric_headers"] == ["activeUsers"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_run_report_calls_api(self, mock_client_cls): + """run_report sends correct request to GA4 API.""" + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.return_value = _make_report_response( + dim_headers=["pagePath"], + metric_headers=["sessions"], + rows=[(["/home"], ["100"])], + ) + + client = _GAClient("/fake/path.json") + result = client.run_report( + property_id="properties/123", + metrics=["sessions"], + dimensions=["pagePath"], + start_date="7daysAgo", + end_date="today", + limit=50, + ) + + mock_api.run_report.assert_called_once() + assert result["row_count"] == 1 + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_run_realtime_report_calls_api(self, mock_client_cls): + """run_realtime_report sends correct request to GA4 API.""" + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_realtime_report.return_value = _make_realtime_response( + metric_headers=["activeUsers"], + rows=[["10"]], + ) + + client = _GAClient("/fake/path.json") + result = client.run_realtime_report( + property_id="properties/123", + metrics=["activeUsers"], + ) + + mock_api.run_realtime_report.assert_called_once() + assert result["rows"][0]["activeUsers"] == "10" + + +# --------------------------------------------------------------------------- +# Credential retrieval tests +# --------------------------------------------------------------------------- + + +class TestCredentialRetrieval: + """Tests for credential resolution in register_tools.""" + + def test_no_credentials_returns_error(self, monkeypatch): + """No credentials configured returns helpful error from tool call.""" + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + mcp = MagicMock() + registered_fns = {} + mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn + + register_tools(mcp, credentials=None) + + result = registered_fns["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + ) + assert "error" in result + assert "not configured" in result["error"] + + def test_credentials_from_env(self, monkeypatch): + """Credentials resolved from environment variable.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/path/to/key.json") + mcp = MagicMock() + registered_fns = {} + mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn + + register_tools(mcp, credentials=None) + assert "ga_run_report" in registered_fns + + def test_credentials_from_credential_store(self): + """Credentials resolved from CredentialStoreAdapter.""" + mcp = MagicMock() + registered_fns = {} + mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn + + cred_manager = MagicMock() + cred_manager.get.return_value = "/path/to/key.json" + + register_tools(mcp, credentials=cred_manager) + assert "ga_run_report" in registered_fns + + +# --------------------------------------------------------------------------- +# ga_run_report tests +# --------------------------------------------------------------------------- + + +class TestGaRunReport: + """Tests for ga_run_report tool function.""" + + @pytest.fixture + def ga_tools(self, monkeypatch): + """Register GA tools without credentials.""" + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + return fns + + @pytest.fixture + def ga_tools_with_creds(self, monkeypatch): + """Register GA tools with credentials set (for input validation tests).""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + with patch( + "aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient" + ): + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + yield fns + + def test_empty_metrics_returns_error(self, ga_tools_with_creds): + """Empty metrics list returns validation error.""" + result = ga_tools_with_creds["ga_run_report"]( + property_id="properties/123", + metrics=[], + ) + assert "error" in result + assert "metrics" in result["error"].lower() + + def test_invalid_property_id_returns_error(self, ga_tools_with_creds): + """Property ID without 'properties/' prefix returns error.""" + result = ga_tools_with_creds["ga_run_report"]( + property_id="123456", + metrics=["sessions"], + ) + assert "error" in result + assert "properties/" in result["error"] + + def test_empty_property_id_returns_error(self, ga_tools_with_creds): + """Empty property ID returns error.""" + result = ga_tools_with_creds["ga_run_report"]( + property_id="", + metrics=["sessions"], + ) + assert "error" in result + + def test_limit_too_low_returns_error(self, ga_tools_with_creds): + """Limit of 0 returns error.""" + result = ga_tools_with_creds["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + limit=0, + ) + assert "error" in result + assert "limit" in result["error"].lower() + + def test_limit_too_high_returns_error(self, ga_tools_with_creds): + """Limit above 10000 returns error.""" + result = ga_tools_with_creds["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + limit=10001, + ) + assert "error" in result + assert "limit" in result["error"].lower() + + def test_no_credentials_returns_error(self, ga_tools): + """No credentials returns error with help message.""" + result = ga_tools["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + ) + assert "error" in result + assert "not configured" in result["error"] + assert "help" in result + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_successful_report(self, mock_client_cls, monkeypatch): + """Successful report returns formatted data.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.return_value = _make_report_response( + dim_headers=["pagePath"], + metric_headers=["sessions"], + rows=[(["/home"], ["500"])], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + dimensions=["pagePath"], + ) + + assert result["row_count"] == 1 + assert result["rows"][0]["pagePath"] == "/home" + assert result["rows"][0]["sessions"] == "500" + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_api_error_returns_error_dict(self, mock_client_cls, monkeypatch): + """API exception is caught and returned as error dict.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.side_effect = Exception("Permission denied") + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + ) + + assert "error" in result + assert "Permission denied" in result["error"] + + +# --------------------------------------------------------------------------- +# ga_get_realtime tests +# --------------------------------------------------------------------------- + + +class TestGaGetRealtime: + """Tests for ga_get_realtime tool function.""" + + @pytest.fixture + def ga_tools(self, monkeypatch): + """Register GA tools without credentials.""" + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + return fns + + @pytest.fixture + def ga_tools_with_creds(self, monkeypatch): + """Register GA tools with credentials set (for input validation tests).""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + with patch( + "aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient" + ): + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + yield fns + + def test_invalid_property_id_returns_error(self, ga_tools_with_creds): + """Property ID without 'properties/' prefix returns error.""" + result = ga_tools_with_creds["ga_get_realtime"](property_id="123456") + assert "error" in result + assert "properties/" in result["error"] + + def test_no_credentials_returns_error(self, ga_tools): + """No credentials returns error.""" + result = ga_tools["ga_get_realtime"](property_id="properties/123") + assert "error" in result + assert "not configured" in result["error"] + + def test_default_metrics(self, ga_tools): + """Default metrics is ['activeUsers'] when none provided.""" + # We can't easily test the default without mocking, but we can + # verify it doesn't crash with None metrics + result = ga_tools["ga_get_realtime"](property_id="properties/123", metrics=None) + assert "error" in result # No credentials, but no crash + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_successful_realtime(self, mock_client_cls, monkeypatch): + """Successful realtime report returns formatted data.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_realtime_report.return_value = _make_realtime_response( + metric_headers=["activeUsers"], + rows=[["42"]], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_realtime"](property_id="properties/123") + + assert result["row_count"] == 1 + assert result["rows"][0]["activeUsers"] == "42" + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_custom_metrics(self, mock_client_cls, monkeypatch): + """Custom metrics are passed through to the API.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_realtime_report.return_value = _make_realtime_response( + metric_headers=["activeUsers", "screenPageViews"], + rows=[["10", "25"]], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_realtime"]( + property_id="properties/123", + metrics=["activeUsers", "screenPageViews"], + ) + + assert result["rows"][0]["activeUsers"] == "10" + assert result["rows"][0]["screenPageViews"] == "25" + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_api_error_returns_error_dict(self, mock_client_cls, monkeypatch): + """API exception is caught and returned as error dict.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_realtime_report.side_effect = Exception("Quota exceeded") + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_realtime"](property_id="properties/123") + + assert "error" in result + assert "Quota exceeded" in result["error"] + + +# --------------------------------------------------------------------------- +# ga_get_top_pages tests +# --------------------------------------------------------------------------- + + +class TestGaGetTopPages: + """Tests for ga_get_top_pages convenience wrapper.""" + + @pytest.fixture + def ga_tools(self, monkeypatch): + """Register GA tools without credentials.""" + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + return fns + + @pytest.fixture + def ga_tools_with_creds(self, monkeypatch): + """Register GA tools with credentials set (for input validation tests).""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + with patch( + "aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient" + ): + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + yield fns + + def test_invalid_property_id_returns_error(self, ga_tools_with_creds): + """Property ID validation works.""" + result = ga_tools_with_creds["ga_get_top_pages"](property_id="bad-id") + assert "error" in result + assert "properties/" in result["error"] + + def test_limit_validation(self, ga_tools_with_creds): + """Limit bounds are checked.""" + result = ga_tools_with_creds["ga_get_top_pages"](property_id="properties/123", limit=0) + assert "error" in result + assert "limit" in result["error"].lower() + + def test_no_credentials_returns_error(self, ga_tools): + """No credentials returns error.""" + result = ga_tools["ga_get_top_pages"](property_id="properties/123") + assert "error" in result + assert "not configured" in result["error"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_correct_dimensions_and_metrics(self, mock_client_cls, monkeypatch): + """Sends pagePath, pageTitle dimensions and page-related metrics.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.return_value = _make_report_response( + dim_headers=["pagePath", "pageTitle"], + metric_headers=["screenPageViews", "averageSessionDuration", "bounceRate"], + rows=[(["/home", "Home Page"], ["1000", "120.5", "0.45"])], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_top_pages"](property_id="properties/123") + + assert result["row_count"] == 1 + assert result["rows"][0]["pagePath"] == "/home" + assert result["rows"][0]["pageTitle"] == "Home Page" + assert result["dimension_headers"] == ["pagePath", "pageTitle"] + assert "screenPageViews" in result["metric_headers"] + assert "averageSessionDuration" in result["metric_headers"] + assert "bounceRate" in result["metric_headers"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_date_range_and_limit_forwarded(self, mock_client_cls, monkeypatch): + """Custom date range and limit are passed to the API.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.return_value = _make_report_response( + dim_headers=["pagePath", "pageTitle"], + metric_headers=["screenPageViews", "averageSessionDuration", "bounceRate"], + rows=[], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + fns["ga_get_top_pages"]( + property_id="properties/123", + start_date="2024-01-01", + end_date="2024-01-31", + limit=5, + ) + + # Verify the API was called (the request object is constructed internally) + mock_api.run_report.assert_called_once() + + +# --------------------------------------------------------------------------- +# ga_get_traffic_sources tests +# --------------------------------------------------------------------------- + + +class TestGaGetTrafficSources: + """Tests for ga_get_traffic_sources convenience wrapper.""" + + @pytest.fixture + def ga_tools(self, monkeypatch): + """Register GA tools without credentials.""" + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + return fns + + @pytest.fixture + def ga_tools_with_creds(self, monkeypatch): + """Register GA tools with credentials set (for input validation tests).""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + with patch( + "aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient" + ): + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + yield fns + + def test_invalid_property_id_returns_error(self, ga_tools_with_creds): + """Property ID validation works.""" + result = ga_tools_with_creds["ga_get_traffic_sources"](property_id="bad-id") + assert "error" in result + assert "properties/" in result["error"] + + def test_limit_validation(self, ga_tools_with_creds): + """Limit bounds are checked.""" + result = ga_tools_with_creds["ga_get_traffic_sources"]( + property_id="properties/123", limit=10001 + ) + assert "error" in result + assert "limit" in result["error"].lower() + + def test_no_credentials_returns_error(self, ga_tools): + """No credentials returns error.""" + result = ga_tools["ga_get_traffic_sources"](property_id="properties/123") + assert "error" in result + assert "not configured" in result["error"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_correct_dimensions_and_metrics(self, mock_client_cls, monkeypatch): + """Sends sessionSource, sessionMedium dimensions and traffic metrics.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.return_value = _make_report_response( + dim_headers=["sessionSource", "sessionMedium"], + metric_headers=["sessions", "totalUsers", "conversions"], + rows=[ + (["google", "organic"], ["500", "400", "10"]), + (["direct", "(none)"], ["200", "180", "5"]), + ], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_traffic_sources"](property_id="properties/123") + + assert result["row_count"] == 2 + assert result["rows"][0]["sessionSource"] == "google" + assert result["rows"][0]["sessionMedium"] == "organic" + assert result["dimension_headers"] == ["sessionSource", "sessionMedium"] + assert "sessions" in result["metric_headers"] + assert "totalUsers" in result["metric_headers"] + assert "conversions" in result["metric_headers"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_api_error_returns_error_dict(self, mock_client_cls, monkeypatch): + """API exception is caught and returned as error dict.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.side_effect = Exception("Service unavailable") + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_traffic_sources"](property_id="properties/123") + + assert "error" in result + assert "Service unavailable" in result["error"] + + +# --------------------------------------------------------------------------- +# Tool registration tests +# --------------------------------------------------------------------------- + + +class TestToolRegistration: + """Tests for tool registration in register_all_tools.""" + + def test_register_tools_registers_all_four_tools(self): + """register_tools registers exactly 4 GA tool functions.""" + mcp = MagicMock() + registered_fns = {} + mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn + + register_tools(mcp, credentials=None) + + expected_tools = { + "ga_run_report", + "ga_get_realtime", + "ga_get_top_pages", + "ga_get_traffic_sources", + } + assert set(registered_fns.keys()) == expected_tools + + def test_register_all_tools_includes_ga_tools(self): + """register_all_tools return list includes all GA tool names.""" + from aden_tools.tools import register_all_tools + + mcp = MagicMock() + mcp.tool.return_value = lambda fn: fn + + result = register_all_tools(mcp, credentials=None) + + for tool_name in [ + "ga_run_report", + "ga_get_realtime", + "ga_get_top_pages", + "ga_get_traffic_sources", + ]: + assert tool_name in result, f"{tool_name} missing from register_all_tools" + + def test_credentials_passed_through(self): + """Credential store adapter is passed to register_tools.""" + mcp = MagicMock() + registered_fns = {} + mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn + + cred_manager = MagicMock() + cred_manager.get.return_value = "/fake/path.json" + + register_tools(mcp, credentials=cred_manager) + + assert len(registered_fns) == 4 diff --git a/uv.lock b/uv.lock index bf58df0e..8b92e4a8 100644 --- a/uv.lock +++ b/uv.lock @@ -959,6 +959,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, ] +[[package]] +name = "google-analytics-data" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/ec/a3ace1e5c6308f79810d24bfa29971bc1d57e3dd114409a5b58619dcaebb/google_analytics_data-0.20.0.tar.gz", hash = "sha256:00b26c813d3153b2f0e0229f3080f10079313cf922dc286c3114a3b33ad51190", size = 225381, upload-time = "2026-01-15T13:14:56.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/9d/84f043f125dc3b8285ed5e68b983bc6fa90abb22129847f98e51fff720a4/google_analytics_data-0.20.0-py3-none-any.whl", hash = "sha256:2b2e7fd1e801897073b8501127c7fb5f12df69fc2c0fb46c264813a51292ea78", size = 192755, upload-time = "2026-01-09T14:52:34.837Z" }, +] + [[package]] name = "google-api-core" version = "2.29.0" @@ -3473,6 +3489,7 @@ dependencies = [ { name = "dnspython" }, { name = "fastmcp" }, { name = "framework" }, + { name = "google-analytics-data" }, { name = "httpx" }, { name = "jsonpath-ng" }, { name = "litellm" }, @@ -3535,6 +3552,7 @@ requires-dist = [ { name = "duckdb", marker = "extra == 'sql'", specifier = ">=1.0.0" }, { name = "fastmcp", specifier = ">=2.0.0" }, { name = "framework", editable = "core" }, + { name = "google-analytics-data", specifier = ">=0.18.0" }, { name = "google-cloud-bigquery", marker = "extra == 'all'", specifier = ">=3.0.0" }, { name = "google-cloud-bigquery", marker = "extra == 'bigquery'", specifier = ">=3.0.0" }, { name = "httpx", specifier = ">=0.27.0" },