From c533f8f85a8611b2974564213b30abcfc49a2814 Mon Sep 17 00:00:00 2001 From: Adam Gustavsson Date: Mon, 29 Sep 2025 23:32:53 +0200 Subject: [PATCH 1/5] feat: optimize tool responses to reduce token consumption MCP tool responses are directly consumed by LLMs, making token count a critical cost factor. This commit significantly reduces token usage while maintaining full functionality. Token Optimization Strategy: - Eliminate repetition in array responses (property_type, parent fields) - Use compact row format with simple arrays vs wrapped objects - Conditionally include fields only when populated (metadata, totals, etc.) - Strip redundant parent resource names - Add optional include_descriptions parameter (default: false) Response Format Changes: - get_account_summaries: Return compact format with simple IDs Savings: ~40% (eliminates repeated property_type/parent for each property) - run_report/run_realtime_report: Compact rows, conditional field inclusion Savings: ~30-50% (less wrapper objects, no empty fields) - get_custom_dimensions_and_metrics: Cleaner field names, optional descriptions Savings: ~25% (descriptions excluded by default) Schema Simplification: - Change property_id parameters to accept only numeric strings (e.g. '213025502') - Remove support for full resource names ('properties/12345') - This creates consistency: tools return IDs that other tools accept - Update construct_property_rn() to enforce numeric string format only Additional Improvements: - Add default limit=100 to run_report and run_realtime_report Prevents accidentally requesting massive responses - Add automatic quota warning when API usage exceeds 90% Helps prevent hitting quota limits unexpectedly - Fix bug: Remove offset parameter from run_realtime_report The Realtime API doesn't support pagination via offset. Attempting to use offset results in 'Unknown field for RunRealtimeReportRequest: offset' error. Tests: - Add comprehensive quota warning tests (6 test cases) - Update construct_property_rn validation tests - All tests passing Breaking Changes: - property_id parameters now require numeric strings only - Response formats are more compact (but contain same data) - Custom dimensions/metrics return different field names (api_name vs apiName) - run_realtime_report no longer accepts offset parameter --- analytics_mcp/tools/admin/info.py | 44 ++-- analytics_mcp/tools/reporting/core.py | 76 ++++++- analytics_mcp/tools/reporting/metadata.py | 33 ++- analytics_mcp/tools/reporting/realtime.py | 75 +++++-- analytics_mcp/tools/utils.py | 39 ++-- tests/quota_test.py | 253 ++++++++++++++++++++++ tests/utils_test.py | 40 ++-- 7 files changed, 474 insertions(+), 86 deletions(-) create mode 100644 tests/quota_test.py diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index a350d29..c10b37e 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -32,20 +32,34 @@ async def get_account_summaries() -> List[Dict[str, Any]]: # 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 + + summaries = [] + async for summary_page in summary_pager: + # Extract just the ID from resource names + account_id = summary_page.account.split('/')[-1] + + summaries.append({ + "account_id": account_id, + "account_name": summary_page.display_name, + "properties": [ + { + "id": ps.property.split('/')[-1], + "name": ps.display_name, + } + for ps in summary_page.property_summaries + ] + }) + + return summaries @mcp.tool(title="List links to Google Ads accounts") -async def list_google_ads_links(property_id: int | str) -> List[Dict[str, Any]]: +async def list_google_ads_links(property_id: str) -> List[Dict[str, Any]]: """Returns a list of links to Google Ads accounts for a property. Args: - property_id: The Google Analytics property ID. Accepted formats are: - - A number - - A string consisting of 'properties/' followed by a number + property_id: The Google Analytics property ID as a string (e.g., "213025502"). + Get property IDs from get_account_summaries(). """ request = admin_v1beta.ListGoogleAdsLinksRequest( parent=construct_property_rn(property_id) @@ -60,16 +74,20 @@ async def list_google_ads_links(property_id: int | str) -> List[Dict[str, Any]]: @mcp.tool(title="Gets details about a property") -async def get_property_details(property_id: int | str) -> Dict[str, Any]: +async def get_property_details(property_id: str) -> Dict[str, Any]: """Returns details about a property. Args: - property_id: The Google Analytics property ID. Accepted formats are: - - A number - - A string consisting of 'properties/' followed by a number + property_id: The Google Analytics property ID as a string (e.g., "213025502"). + Get property IDs from get_account_summaries(). """ 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) + + # Convert to dict and remove redundant parent field + result = proto_to_dict(response) + result.pop("parent", None) + + return result diff --git a/analytics_mcp/tools/reporting/core.py b/analytics_mcp/tools/reporting/core.py index 881fb4f..d54ce34 100644 --- a/analytics_mcp/tools/reporting/core.py +++ b/analytics_mcp/tools/reporting/core.py @@ -80,14 +80,14 @@ def _run_report_description() -> str: async def run_report( - property_id: int | str, - date_ranges: List[Dict[str, str]], + property_id: str, + date_ranges: List[Dict[str, Any]], dimensions: List[str], metrics: List[str], dimension_filter: Dict[str, Any] = None, metric_filter: Dict[str, Any] = None, order_bys: List[Dict[str, Any]] = None, - limit: int = None, + limit: int = 100, offset: int = None, currency_code: str = None, return_property_quota: bool = False, @@ -102,9 +102,8 @@ async def run_report( https://github.com/googleapis/googleapis/tree/master/google/analytics/data/v1beta. Args: - property_id: The Google Analytics property ID. Accepted formats are: - - A number - - A string consisting of 'properties/' followed by a number + property_id: The Google Analytics property ID as a string (e.g., "213025502"). + Get property IDs from get_account_summaries(). date_ranges: A list of date ranges (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/DateRange) to include in the report. @@ -138,6 +137,7 @@ async def run_report( report uses the property's default currency. return_property_quota: Whether to return property quota in the response. """ + # Always request quota to check if we're approaching limits request = data_v1beta.RunReportRequest( property=construct_property_rn(property_id), dimensions=[ @@ -145,7 +145,7 @@ async def run_report( ], metrics=[data_v1beta.Metric(name=metric) for metric in metrics], date_ranges=[data_v1beta.DateRange(dr) for dr in date_ranges], - return_property_quota=return_property_quota, + return_property_quota=True, ) if dimension_filter: @@ -170,7 +170,67 @@ async def run_report( response = await create_data_api_client().run_report(request) - return proto_to_dict(response) + # Compact format - eliminate repetition + result = { + "row_count": response.row_count, + "dimension_headers": [h.name for h in response.dimension_headers], + "metric_headers": [h.name for h in response.metric_headers], + "rows": [ + { + "dimensions": [dv.value for dv in row.dimension_values], + "metrics": [mv.value for mv in row.metric_values] + } + for row in response.rows + ] if response.rows else [] + } + + # Include metadata (exclude empty/false values) + if response.metadata: + metadata = {} + if response.metadata.currency_code: + metadata["currency_code"] = response.metadata.currency_code + if response.metadata.time_zone: + metadata["time_zone"] = response.metadata.time_zone + if response.metadata.data_loss_from_other_row: + metadata["data_loss_from_other_row"] = True + if response.metadata.sampling_metadatas: + metadata["sampling_metadatas"] = [proto_to_dict(sm) for sm in response.metadata.sampling_metadatas] + if metadata: + result["metadata"] = metadata + + # Include totals/maximums/minimums only if they have data + if response.totals: + result["totals"] = [proto_to_dict(total) for total in response.totals] + if response.maximums: + result["maximums"] = [proto_to_dict(maximum) for maximum in response.maximums] + if response.minimums: + result["minimums"] = [proto_to_dict(minimum) for minimum in response.minimums] + + # Check quota usage and include if >90% used or explicitly requested + if response.property_quota: + quota_dict = proto_to_dict(response.property_quota) + quota_warning = None + + # Check if any quota metric is >90% used + for quota_name, quota_info in quota_dict.items(): + if isinstance(quota_info, dict) and "consumed" in quota_info and "remaining" in quota_info: + consumed = quota_info.get("consumed", 0) + remaining = quota_info.get("remaining", 0) + total = consumed + remaining + if total > 0 and (consumed / total) > 0.9: + quota_warning = ( + f"WARNING: {quota_name} is at {(consumed / total * 100):.1f}% " + f"({consumed}/{total}). Approaching quota limit." + ) + break + + # Include quota if explicitly requested or if usage >90% + if return_property_quota or quota_warning: + result["quota"] = quota_dict + if quota_warning: + result["quota_warning"] = quota_warning + + return result # The `run_report` tool requires a more complex description that's generated at diff --git a/analytics_mcp/tools/reporting/metadata.py b/analytics_mcp/tools/reporting/metadata.py index 1fa7a63..eca73df 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -319,30 +319,47 @@ def get_order_bys_hints(): title="Retrieves the custom Core Reporting dimensions and metrics for a specific property" ) async def get_custom_dimensions_and_metrics( - property_id: int | str, + property_id: str, + include_descriptions: bool = False, ) -> Dict[str, List[Dict[str, Any]]]: """Returns the property's custom dimensions and metrics. Args: - property_id: The Google Analytics property ID. Accepted formats are: - - A number - - A string consisting of 'properties/' followed by a number + property_id: The Google Analytics property ID as a string (e.g., "213025502"). + Get property IDs from get_account_summaries(). + include_descriptions: Whether to include user-written descriptions (default: False). + Descriptions can be helpful for understanding custom dimensions/metrics but + increase token usage. """ metadata = await create_data_api_client().get_metadata( name=f"{construct_property_rn(property_id)}/metadata" ) + custom_metrics = [ - proto_to_dict(metric) + { + "api_name": metric.api_name, + "display_name": metric.ui_name, + "scope": metric.category, + "type": metric.type_.name if metric.type_ else "STANDARD", + **({"description": metric.description} if include_descriptions and metric.description else {}) + } for metric in metadata.metrics if metric.custom_definition ] + custom_dimensions = [ - proto_to_dict(dimension) + { + "api_name": dimension.api_name, + "display_name": dimension.ui_name, + "scope": dimension.category, + **({"description": dimension.description} if include_descriptions and dimension.description else {}) + } for dimension in metadata.dimensions if dimension.custom_definition ] + return { - "custom_dimensions": custom_dimensions, - "custom_metrics": custom_metrics, + "dimensions": custom_dimensions, + "metrics": custom_metrics, } diff --git a/analytics_mcp/tools/reporting/realtime.py b/analytics_mcp/tools/reporting/realtime.py index 548882c..766010d 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -78,14 +78,13 @@ def _run_realtime_report_description() -> str: async def run_realtime_report( - property_id: int | str, + property_id: str, dimensions: List[str], metrics: List[str], dimension_filter: Dict[str, Any] = None, metric_filter: Dict[str, Any] = None, order_bys: List[Dict[str, Any]] = None, - limit: int = None, - offset: int = None, + limit: int = 100, return_property_quota: bool = False, ) -> Dict[str, Any]: """Runs a Google Analytics Data API realtime report. @@ -95,9 +94,8 @@ async def run_realtime_report( for more information. Args: - property_id: The Google Analytics property ID. Accepted formats are: - - A number - - A string consisting of 'properties/' followed by a number + property_id: The Google Analytics property ID as a string (e.g., "213025502"). + Get property IDs from get_account_summaries(). dimensions: A list of dimensions to include in the report. Dimensions must be realtime dimensions. metrics: A list of metrics to include in the report. Metrics must be realtime metrics. dimension_filter: A Data API FilterExpression @@ -121,23 +119,19 @@ async def run_realtime_report( objects to apply to the dimensions and metrics. For more information about the expected format of this argument, see the `run_report_order_bys_hints` tool. - limit: The maximum number of rows to return in each response. Value must - be a positive integer <= 250,000. Used to paginate through large - reports, following the guide at - https://developers.google.com/analytics/devguides/reporting/data/v1/basics#pagination. - offset: The row count of the start row. The first row is counted as row - 0. Used to paginate through large - reports, following the guide at - https://developers.google.com/analytics/devguides/reporting/data/v1/basics#pagination. + limit: The maximum number of rows to return. Value must be a positive + integer <= 100,000. Default is 100. If unspecified by the API, it returns + up to 10,000 rows. return_property_quota: Whether to return realtime property quota in the response. """ + # Always request quota to check if we're approaching limits request = data_v1beta.RunRealtimeReportRequest( property=construct_property_rn(property_id), dimensions=[ data_v1beta.Dimension(name=dimension) for dimension in dimensions ], metrics=[data_v1beta.Metric(name=metric) for metric in metrics], - return_property_quota=return_property_quota, + return_property_quota=True, ) if dimension_filter: @@ -155,11 +149,56 @@ async def run_realtime_report( if limit: request.limit = limit - if offset: - request.offset = offset response = await create_data_api_client().run_realtime_report(request) - return proto_to_dict(response) + + # Compact format - eliminate repetition + result = { + "row_count": response.row_count, + "dimension_headers": [h.name for h in response.dimension_headers], + "metric_headers": [h.name for h in response.metric_headers], + "rows": [ + { + "dimensions": [dv.value for dv in row.dimension_values], + "metrics": [mv.value for mv in row.metric_values] + } + for row in response.rows + ] if response.rows else [] + } + + # Include totals/maximums/minimums only if they have data + if response.totals: + result["totals"] = [proto_to_dict(total) for total in response.totals] + if response.maximums: + result["maximums"] = [proto_to_dict(maximum) for maximum in response.maximums] + if response.minimums: + result["minimums"] = [proto_to_dict(minimum) for minimum in response.minimums] + + # Check quota usage and include if >90% used or explicitly requested + if response.property_quota: + quota_dict = proto_to_dict(response.property_quota) + quota_warning = None + + # Check if any quota metric is >90% used + for quota_name, quota_info in quota_dict.items(): + if isinstance(quota_info, dict) and "consumed" in quota_info and "remaining" in quota_info: + consumed = quota_info.get("consumed", 0) + remaining = quota_info.get("remaining", 0) + total = consumed + remaining + if total > 0 and (consumed / total) > 0.9: + quota_warning = ( + f"WARNING: {quota_name} is at {(consumed / total * 100):.1f}% " + f"({consumed}/{total}). Approaching quota limit." + ) + break + + # Include quota if explicitly requested or if usage >90% + if return_property_quota or quota_warning: + result["quota"] = quota_dict + if quota_warning: + result["quota_warning"] = quota_warning + + return result # The `run_realtime_report` tool requires a more complex description that's generated at diff --git a/analytics_mcp/tools/utils.py b/analytics_mcp/tools/utils.py index 34eec86..5a9b42f 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -71,28 +71,27 @@ def create_data_api_client() -> data_v1beta.BetaAnalyticsDataAsyncClient: ) -def construct_property_rn(property_value: int | str) -> str: - """Returns a property resource name in the format required by APIs.""" - property_num = None - if isinstance(property_value, int): - property_num = property_value - elif isinstance(property_value, str): - property_value = property_value.strip() - if property_value.isdigit(): - property_num = int(property_value) - elif property_value.startswith("properties/"): - numeric_part = property_value.split("/")[-1] - if numeric_part.isdigit(): - property_num = int(numeric_part) - if property_num is None: +def construct_property_rn(property_value: str) -> str: + """Returns a property resource name in the format required by APIs. + + Args: + property_value: A property ID as a numeric string (e.g., "213025502"). + Get property IDs from get_account_summaries(). + + Returns: + A property resource name in the format "properties/{property_id}". + + Raises: + ValueError: If property_value is not a numeric string. + """ + property_value = property_value.strip() + if not property_value.isdigit(): raise ValueError( - ( - f"Invalid property ID: {property_value}. " - "A valid property value is either a number or a string starting " - "with 'properties/' and followed by a number." - ) + f"Invalid property ID: {property_value}. " + "Expected a numeric string (e.g., '213025502'). " + "Get property IDs from get_account_summaries()." ) - + property_num = int(property_value) return f"properties/{property_num}" diff --git a/tests/quota_test.py b/tests/quota_test.py new file mode 100644 index 0000000..b154a5a --- /dev/null +++ b/tests/quota_test.py @@ -0,0 +1,253 @@ +# 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. + +"""Test cases for quota warning functionality.""" + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from analytics_mcp.tools.reporting import core, realtime + + +class TestQuotaWarning(unittest.IsolatedAsyncioTestCase): + """Test cases for quota warning functionality in reporting tools.""" + + def _create_mock_response(self, quota_consumed, quota_remaining): + """Helper to create a mock response with quota data.""" + mock_response = MagicMock() + mock_response.row_count = 5 + mock_response.dimension_headers = [MagicMock(name="country")] + mock_response.metric_headers = [MagicMock(name="sessions")] + mock_response.rows = [] + mock_response.metadata = None + mock_response.totals = None + mock_response.maximums = None + mock_response.minimums = None + + # Create quota mock + mock_quota = MagicMock() + mock_quota_dict = { + "tokens_per_day": { + "consumed": quota_consumed, + "remaining": quota_remaining + }, + "tokens_per_hour": { + "consumed": 10, + "remaining": 39990 + } + } + mock_response.property_quota = mock_quota + + return mock_response, mock_quota_dict + + @patch('analytics_mcp.tools.reporting.core.create_data_api_client') + @patch('analytics_mcp.tools.reporting.core.construct_property_rn') + @patch('analytics_mcp.tools.reporting.core.proto_to_dict') + async def test_quota_warning_not_triggered_low_usage( + self, mock_proto_to_dict, mock_construct_rn, mock_client + ): + """Tests that quota warning is NOT triggered when usage is low (<90%).""" + # Setup mocks for low usage (1%) + mock_construct_rn.return_value = "properties/12345" + mock_response, mock_quota_dict = self._create_mock_response( + quota_consumed=100, quota_remaining=9900 + ) + mock_client.return_value.run_report = AsyncMock(return_value=mock_response) + mock_proto_to_dict.return_value = mock_quota_dict + + # Call run_report with return_property_quota=False + result = await core.run_report( + property_id="12345", + date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], + dimensions=["country"], + metrics=["sessions"], + return_property_quota=False + ) + + # Verify quota is NOT included (usage < 90%) + self.assertNotIn("quota", result, "Quota should not be included when usage < 90%") + self.assertNotIn("quota_warning", result, "No warning should be present when usage < 90%") + + @patch('analytics_mcp.tools.reporting.core.create_data_api_client') + @patch('analytics_mcp.tools.reporting.core.construct_property_rn') + @patch('analytics_mcp.tools.reporting.core.proto_to_dict') + async def test_quota_warning_triggered_high_usage( + self, mock_proto_to_dict, mock_construct_rn, mock_client + ): + """Tests that quota warning IS triggered when usage is high (>90%).""" + # Setup mocks for high usage (91%) + mock_construct_rn.return_value = "properties/12345" + mock_response, mock_quota_dict = self._create_mock_response( + quota_consumed=18200, quota_remaining=1800 + ) + mock_client.return_value.run_report = AsyncMock(return_value=mock_response) + mock_proto_to_dict.return_value = mock_quota_dict + + # Call run_report with return_property_quota=False + result = await core.run_report( + property_id="12345", + date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], + dimensions=["country"], + metrics=["sessions"], + return_property_quota=False + ) + + # Verify quota IS included (usage > 90%) + self.assertIn("quota", result, "Quota should be included when usage > 90%") + self.assertIn("quota_warning", result, "Warning should be present when usage > 90%") + + # Verify warning message format + warning = result["quota_warning"] + self.assertIn("WARNING", warning) + self.assertIn("tokens_per_day", warning) + self.assertIn("91.0%", warning) + self.assertIn("18200/20000", warning) + + @patch('analytics_mcp.tools.reporting.core.create_data_api_client') + @patch('analytics_mcp.tools.reporting.core.construct_property_rn') + @patch('analytics_mcp.tools.reporting.core.proto_to_dict') + async def test_quota_warning_edge_case_exactly_90_percent( + self, mock_proto_to_dict, mock_construct_rn, mock_client + ): + """Tests that quota warning is NOT triggered at exactly 90% (threshold is >90%).""" + # Setup mocks for exactly 90% usage + mock_construct_rn.return_value = "properties/12345" + mock_response, mock_quota_dict = self._create_mock_response( + quota_consumed=9000, quota_remaining=1000 + ) + mock_client.return_value.run_report = AsyncMock(return_value=mock_response) + mock_proto_to_dict.return_value = mock_quota_dict + + # Call run_report with return_property_quota=False + result = await core.run_report( + property_id="12345", + date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], + dimensions=["country"], + metrics=["sessions"], + return_property_quota=False + ) + + # Verify quota is NOT included (exactly 90% is not > 90%) + self.assertNotIn("quota", result, "Quota should not be included at exactly 90%") + self.assertNotIn("quota_warning", result, "No warning at exactly 90%") + + @patch('analytics_mcp.tools.reporting.core.create_data_api_client') + @patch('analytics_mcp.tools.reporting.core.construct_property_rn') + @patch('analytics_mcp.tools.reporting.core.proto_to_dict') + async def test_quota_included_when_explicitly_requested( + self, mock_proto_to_dict, mock_construct_rn, mock_client + ): + """Tests that quota is always included when explicitly requested.""" + # Setup mocks for low usage + mock_construct_rn.return_value = "properties/12345" + mock_response, mock_quota_dict = self._create_mock_response( + quota_consumed=10, quota_remaining=19990 + ) + mock_client.return_value.run_report = AsyncMock(return_value=mock_response) + mock_proto_to_dict.return_value = mock_quota_dict + + # Call run_report with return_property_quota=True + result = await core.run_report( + property_id="12345", + date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], + dimensions=["country"], + metrics=["sessions"], + return_property_quota=True + ) + + # Verify quota IS included even with low usage + self.assertIn("quota", result, "Quota should be included when explicitly requested") + + @patch('analytics_mcp.tools.reporting.realtime.create_data_api_client') + @patch('analytics_mcp.tools.reporting.realtime.construct_property_rn') + @patch('analytics_mcp.tools.reporting.realtime.proto_to_dict') + async def test_realtime_quota_warning_triggered( + self, mock_proto_to_dict, mock_construct_rn, mock_client + ): + """Tests that quota warning works for realtime reports too.""" + # Setup mocks for high usage (95%) + mock_construct_rn.return_value = "properties/12345" + mock_response, mock_quota_dict = self._create_mock_response( + quota_consumed=19000, quota_remaining=1000 + ) + mock_client.return_value.run_realtime_report = AsyncMock(return_value=mock_response) + mock_proto_to_dict.return_value = mock_quota_dict + + # Call run_realtime_report with return_property_quota=False + result = await realtime.run_realtime_report( + property_id="12345", + dimensions=["country"], + metrics=["activeUsers"], + return_property_quota=False + ) + + # Verify quota warning for realtime + self.assertIn("quota", result, "Quota should be included for realtime when usage > 90%") + self.assertIn("quota_warning", result, "Warning should be present for realtime") + + # Verify warning format + warning = result["quota_warning"] + self.assertIn("WARNING", warning) + self.assertIn("95.0%", warning) + + @patch('analytics_mcp.tools.reporting.core.create_data_api_client') + @patch('analytics_mcp.tools.reporting.core.construct_property_rn') + @patch('analytics_mcp.tools.reporting.core.proto_to_dict') + async def test_quota_warning_checks_multiple_metrics( + self, mock_proto_to_dict, mock_construct_rn, mock_client + ): + """Tests that quota warning checks all quota metrics and triggers on first >90%.""" + # Setup mocks with tokens_per_day OK but tokens_per_hour high + mock_construct_rn.return_value = "properties/12345" + mock_response = MagicMock() + mock_response.row_count = 5 + mock_response.dimension_headers = [MagicMock(name="country")] + mock_response.metric_headers = [MagicMock(name="sessions")] + mock_response.rows = [] + mock_response.metadata = None + mock_response.totals = None + mock_response.maximums = None + mock_response.minimums = None + + mock_quota_dict = { + "tokens_per_day": { + "consumed": 100, + "remaining": 199900 + }, + "tokens_per_hour": { + "consumed": 36400, + "remaining": 3600 + } + } + mock_response.property_quota = MagicMock() + mock_client.return_value.run_report = AsyncMock(return_value=mock_response) + mock_proto_to_dict.return_value = mock_quota_dict + + # Call run_report + result = await core.run_report( + property_id="12345", + date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], + dimensions=["country"], + metrics=["sessions"], + return_property_quota=False + ) + + # Verify warning is triggered by tokens_per_hour + self.assertIn("quota_warning", result) + self.assertIn("tokens_per_hour", result["quota_warning"]) + self.assertIn("91.0%", result["quota_warning"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/utils_test.py b/tests/utils_test.py index a521836..e1521a1 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -24,11 +24,6 @@ class TestUtils(unittest.TestCase): def test_construct_property_rn(self): """Tests construct_property_rn using valid input.""" - self.assertEqual( - utils.construct_property_rn(12345), - "properties/12345", - "Numeric property ID should b considered valid", - ) self.assertEqual( utils.construct_property_rn("12345"), "properties/12345", @@ -37,34 +32,41 @@ def test_construct_property_rn(self): self.assertEqual( utils.construct_property_rn(" 12345 "), "properties/12345", - "Whitespace around property ID should be considered valid", + "Whitespace around property ID should be trimmed and considered valid", ) self.assertEqual( - utils.construct_property_rn("properties/12345"), - "properties/12345", - "Full resource name should be considered valid", + utils.construct_property_rn("213025502"), + "properties/213025502", + "Real-world property ID should be considered valid", ) def test_construct_property_rn_invalid_input(self): """Tests that construct_property_rn raises a ValueError for invalid input.""" - with self.assertRaises(ValueError, msg="None should fail"): - utils.construct_property_rn(None) - with self.assertRaises(ValueError, msg="Empty string should fail"): + with self.assertRaises( + ValueError, msg="Empty string should fail" + ): utils.construct_property_rn("") + with self.assertRaises( + ValueError, msg="Whitespace-only string should fail" + ): + utils.construct_property_rn(" ") with self.assertRaises( ValueError, msg="Non-numeric string should fail" ): utils.construct_property_rn("abc") with self.assertRaises( - ValueError, msg="Resource name without ID should fail" + ValueError, msg="Alphanumeric string should fail" + ): + utils.construct_property_rn("abc123") + with self.assertRaises( + ValueError, msg="Negative number string should fail" ): - utils.construct_property_rn("properties/") + utils.construct_property_rn("-12345") with self.assertRaises( - ValueError, msg="Resource name with non-numeric ID should fail" + ValueError, msg="Full resource name format no longer supported" ): - utils.construct_property_rn("properties/abc") + utils.construct_property_rn("properties/12345") with self.assertRaises( - ValueError, - msg="Resource name with more than 2 components should fail", + ValueError, msg="Number with decimal should fail" ): - utils.construct_property_rn("properties/123/abc") + utils.construct_property_rn("123.45") \ No newline at end of file From 58c0363b5a02ba66b19058702730521f3918ba52 Mon Sep 17 00:00:00 2001 From: Adam Gustavsson Date: Fri, 3 Oct 2025 07:58:55 +0200 Subject: [PATCH 2/5] Format code (black + isort) --- analytics_mcp/server.py | 3 +- analytics_mcp/tools/admin/info.py | 60 +++---- analytics_mcp/tools/reporting/core.py | 138 +++++++++------- analytics_mcp/tools/reporting/metadata.py | 67 +++++--- analytics_mcp/tools/reporting/realtime.py | 136 +++++++++------- analytics_mcp/tools/utils.py | 6 +- tests/quota_test.py | 188 ++++++++++++++-------- tests/utils_test.py | 5 +- 8 files changed, 364 insertions(+), 239 deletions(-) diff --git a/analytics_mcp/server.py b/analytics_mcp/server.py index 234f493..5205047 100755 --- a/analytics_mcp/server.py +++ b/analytics_mcp/server.py @@ -17,14 +17,13 @@ """Entry point for the Google Analytics MCP server.""" from analytics_mcp.coordinator import mcp - # The following imports are necessary to register the tools with the `mcp` # object, even though they are not directly used in this file. # The `# noqa: F401` comment tells the linter to ignore the "unused import" # warning. from analytics_mcp.tools.admin import info # noqa: F401 -from analytics_mcp.tools.reporting import realtime # noqa: F401 from analytics_mcp.tools.reporting import core # noqa: F401 +from analytics_mcp.tools.reporting import realtime # noqa: F401 def run_server() -> None: diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index c10b37e..795498a 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -16,40 +16,44 @@ from typing import Any, Dict, List -from analytics_mcp.coordinator import mcp -from analytics_mcp.tools.utils import ( - construct_property_rn, - create_admin_api_client, - proto_to_dict, -) from google.analytics import admin_v1beta +from analytics_mcp.coordinator import mcp +from analytics_mcp.tools.utils import (construct_property_rn, + create_admin_api_client, proto_to_dict) + @mcp.tool() async def get_account_summaries() -> List[Dict[str, Any]]: - """Retrieves information about the user's Google Analytics accounts and properties.""" + """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() - + summary_pager = ( + await create_admin_api_client().list_account_summaries() + ) + summaries = [] async for summary_page in summary_pager: # Extract just the ID from resource names - account_id = summary_page.account.split('/')[-1] - - summaries.append({ - "account_id": account_id, - "account_name": summary_page.display_name, - "properties": [ - { - "id": ps.property.split('/')[-1], - "name": ps.display_name, - } - for ps in summary_page.property_summaries - ] - }) - + account_id = summary_page.account.split("/")[-1] + + summaries.append( + { + "account_id": account_id, + "account_name": summary_page.display_name, + "properties": [ + { + "id": ps.property.split("/")[-1], + "name": ps.display_name, + } + for ps in summary_page.property_summaries + ], + } + ) + return summaries @@ -58,8 +62,9 @@ async def list_google_ads_links(property_id: str) -> List[Dict[str, Any]]: """Returns a list of links to Google Ads accounts for a property. Args: - property_id: The Google Analytics property ID as a string (e.g., "213025502"). - Get property IDs from get_account_summaries(). + property_id: The Google Analytics property ID as a string + (e.g., "213025502"). Get property IDs from + get_account_summaries(). """ request = admin_v1beta.ListGoogleAdsLinksRequest( parent=construct_property_rn(property_id) @@ -77,8 +82,9 @@ async def list_google_ads_links(property_id: str) -> List[Dict[str, Any]]: async def get_property_details(property_id: str) -> Dict[str, Any]: """Returns details about a property. Args: - property_id: The Google Analytics property ID as a string (e.g., "213025502"). - Get property IDs from get_account_summaries(). + property_id: The Google Analytics property ID as a string + (e.g., "213025502"). Get property IDs from + get_account_summaries(). """ client = create_admin_api_client() request = admin_v1beta.GetPropertyRequest( diff --git a/analytics_mcp/tools/reporting/core.py b/analytics_mcp/tools/reporting/core.py index d54ce34..b48f46d 100644 --- a/analytics_mcp/tools/reporting/core.py +++ b/analytics_mcp/tools/reporting/core.py @@ -16,20 +16,16 @@ from typing import Any, Dict, List -from analytics_mcp.coordinator import mcp -from analytics_mcp.tools.reporting.metadata import ( - get_date_ranges_hints, - get_dimension_filter_hints, - get_metric_filter_hints, - get_order_bys_hints, -) -from analytics_mcp.tools.utils import ( - construct_property_rn, - create_data_api_client, - proto_to_dict, -) from google.analytics import data_v1beta +from analytics_mcp.coordinator import mcp +from analytics_mcp.tools.reporting.metadata import (get_date_ranges_hints, + get_dimension_filter_hints, + get_metric_filter_hints, + get_order_bys_hints) +from analytics_mcp.tools.utils import (construct_property_rn, + create_data_api_client, proto_to_dict) + def _run_report_description() -> str: """Returns the description for the `run_report` tool.""" @@ -46,18 +42,20 @@ def _run_report_description() -> str: The `dimensions` list must consist solely of either of the following: 1. Standard dimensions defined in the HTML table at - https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema#dimensions. + https://developers.google.com/analytics/devguides/reporting/ + data/v1/api-schema#dimensions. These dimensions are available to *every* property. 2. Custom dimensions for the `property_id`. Use the - `get_custom_dimensions_and_metrics` tool to retrieve the list of - custom dimensions for a property. + `get_custom_dimensions_and_metrics` tool to retrieve the list + of custom dimensions for a property. ### Hints for `metrics` The `metrics` list must consist solely of either of the following: 1. Standard metrics defined in the HTML table at - https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema#metrics. + https://developers.google.com/analytics/devguides/reporting/ + data/v1/api-schema#metrics. These metrics are available to *every* property. 2. Custom metrics for the `property_id`. Use the `get_custom_dimensions_and_metrics` tool to retrieve the list of @@ -95,47 +93,58 @@ async def run_report( """Runs a Google Analytics Data API report. Note that the reference docs at - https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta - all use camelCase field names, but field names passed to this method should - be in snake_case since the tool is using the protocol buffers (protobuf) - format. The protocol buffers for the Data API are available at - https://github.com/googleapis/googleapis/tree/master/google/analytics/data/v1beta. + https://developers.google.com/analytics/devguides/reporting/data/ + v1/rest/v1beta all use camelCase field names, but field names + passed to this method should be in snake_case since the tool is + using the protocol buffers (protobuf) format. The protocol + buffers for the Data API are available at + https://github.com/googleapis/googleapis/tree/master/google/ + analytics/data/v1beta. Args: - property_id: The Google Analytics property ID as a string (e.g., "213025502"). - Get property IDs from get_account_summaries(). + property_id: The Google Analytics property ID as a string + (e.g., "213025502"). Get property IDs from + get_account_summaries(). date_ranges: A list of date ranges - (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/DateRange) + (https://developers.google.com/analytics/devguides/ + reporting/data/v1/rest/v1beta/DateRange) to include in the report. dimensions: A list of dimensions to include in the report. metrics: A list of metrics to include in the report. dimension_filter: A Data API FilterExpression - (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression) - to apply to the dimensions. Don't use this for filtering metrics. Use - metric_filter instead. The `field_name` in a `dimension_filter` must - be a dimension, as defined in the `get_standard_dimensions` and - `get_dimensions` tools. + (https://developers.google.com/analytics/devguides/ + reporting/data/v1/rest/v1beta/FilterExpression) + to apply to the dimensions. Don't use this for filtering + metrics. Use metric_filter instead. The `field_name` in a + `dimension_filter` must be a dimension, as defined in the + `get_standard_dimensions` and `get_dimensions` tools. metric_filter: A Data API FilterExpression - (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression) - to apply to the metrics. Don't use this for filtering dimensions. Use - dimension_filter instead. The `field_name` in a `metric_filter` must - be a metric, as defined in the `get_standard_metrics` and - `get_metrics` tools. + (https://developers.google.com/analytics/devguides/ + reporting/data/v1/rest/v1beta/FilterExpression) + to apply to the metrics. Don't use this for filtering + dimensions. Use dimension_filter instead. The `field_name` + in a `metric_filter` must be a metric, as defined in the + `get_standard_metrics` and `get_metrics` tools. order_bys: A list of Data API OrderBy - (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/OrderBy) + (https://developers.google.com/analytics/devguides/ + reporting/data/v1/rest/v1beta/OrderBy) objects to apply to the dimensions and metrics. - limit: The maximum number of rows to return in each response. Value must - be a positive integer <= 250,000. Used to paginate through large - reports, following the guide at - https://developers.google.com/analytics/devguides/reporting/data/v1/basics#pagination. - offset: The row count of the start row. The first row is counted as row - 0. Used to paginate through large - reports, following the guide at - https://developers.google.com/analytics/devguides/reporting/data/v1/basics#pagination. - currency_code: The currency code to use for currency values. Must be in - ISO4217 format, such as "AED", "USD", "JPY". If the field is empty, the - report uses the property's default currency. - return_property_quota: Whether to return property quota in the response. + limit: The maximum number of rows to return in each response. + Value must be a positive integer <= 250,000. Used to + paginate through large reports, following the guide at + https://developers.google.com/analytics/devguides/ + reporting/data/v1/basics#pagination. + offset: The row count of the start row. The first row is + counted as row 0. Used to paginate through large reports, + following the guide at + https://developers.google.com/analytics/devguides/ + reporting/data/v1/basics#pagination. + currency_code: The currency code to use for currency values. + Must be in ISO4217 format, such as "AED", "USD", "JPY". + If the field is empty, the report uses the property's + default currency. + return_property_quota: Whether to return property quota in + the response. """ # Always request quota to check if we're approaching limits request = data_v1beta.RunReportRequest( @@ -194,33 +203,48 @@ async def run_report( if response.metadata.data_loss_from_other_row: metadata["data_loss_from_other_row"] = True if response.metadata.sampling_metadatas: - metadata["sampling_metadatas"] = [proto_to_dict(sm) for sm in response.metadata.sampling_metadatas] + metadata["sampling_metadatas"] = [ + proto_to_dict(sm) + for sm in response.metadata.sampling_metadatas + ] if metadata: result["metadata"] = metadata - + # Include totals/maximums/minimums only if they have data if response.totals: - result["totals"] = [proto_to_dict(total) for total in response.totals] + result["totals"] = [ + proto_to_dict(total) for total in response.totals + ] if response.maximums: - result["maximums"] = [proto_to_dict(maximum) for maximum in response.maximums] + result["maximums"] = [ + proto_to_dict(maximum) for maximum in response.maximums + ] if response.minimums: - result["minimums"] = [proto_to_dict(minimum) for minimum in response.minimums] - + result["minimums"] = [ + proto_to_dict(minimum) for minimum in response.minimums + ] + # Check quota usage and include if >90% used or explicitly requested if response.property_quota: quota_dict = proto_to_dict(response.property_quota) quota_warning = None - + # Check if any quota metric is >90% used for quota_name, quota_info in quota_dict.items(): - if isinstance(quota_info, dict) and "consumed" in quota_info and "remaining" in quota_info: + if ( + isinstance(quota_info, dict) + and "consumed" in quota_info + and "remaining" in quota_info + ): consumed = quota_info.get("consumed", 0) remaining = quota_info.get("remaining", 0) total = consumed + remaining if total > 0 and (consumed / total) > 0.9: quota_warning = ( - f"WARNING: {quota_name} is at {(consumed / total * 100):.1f}% " - f"({consumed}/{total}). Approaching quota limit." + f"WARNING: {quota_name} is at " + f"{(consumed / total * 100):.1f}% " + f"({consumed}/{total}). " + f"Approaching quota limit." ) break diff --git a/analytics_mcp/tools/reporting/metadata.py b/analytics_mcp/tools/reporting/metadata.py index eca73df..4db9d77 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -16,15 +16,13 @@ from typing import Any, Dict, List -from analytics_mcp.coordinator import mcp -from analytics_mcp.tools.utils import ( - construct_property_rn, - create_data_api_client, - proto_to_dict, - proto_to_json, -) from google.analytics import data_v1beta +from analytics_mcp.coordinator import mcp +from analytics_mcp.tools.utils import (construct_property_rn, + create_data_api_client, proto_to_dict, + proto_to_json) + def get_date_ranges_hints(): range_jan = data_v1beta.DateRange( @@ -122,7 +120,9 @@ def get_metric_filter_hints(): filter=data_v1beta.Filter( field_name="eventCount", numeric_filter=data_v1beta.Filter.NumericFilter( - operation=data_v1beta.Filter.NumericFilter.Operation.GREATER_THAN, + operation=( + data_v1beta.Filter.NumericFilter.Operation.GREATER_THAN + ), value=data_v1beta.NumericValue(int64_value=10), ), ) @@ -183,7 +183,9 @@ def get_dimension_filter_hints(): filter=data_v1beta.Filter( field_name="eventName", string_filter=data_v1beta.Filter.StringFilter( - match_type=data_v1beta.Filter.StringFilter.MatchType.BEGINS_WITH, + match_type=( + data_v1beta.Filter.StringFilter.MatchType.BEGINS_WITH + ), value="add", ), ) @@ -249,21 +251,27 @@ def get_order_bys_hints(): dimension_alphanumeric_ascending = data_v1beta.OrderBy( dimension=data_v1beta.OrderBy.DimensionOrderBy( dimension_name="eventName", - order_type=data_v1beta.OrderBy.DimensionOrderBy.OrderType.ALPHANUMERIC, + order_type=( + data_v1beta.OrderBy.DimensionOrderBy.OrderType.ALPHANUMERIC + ), ), desc=False, ) dimension_alphanumeric_no_case_descending = data_v1beta.OrderBy( dimension=data_v1beta.OrderBy.DimensionOrderBy( dimension_name="campaignName", - order_type=data_v1beta.OrderBy.DimensionOrderBy.OrderType.CASE_INSENSITIVE_ALPHANUMERIC, + order_type=( + data_v1beta.OrderBy.DimensionOrderBy.OrderType.CASE_INSENSITIVE_ALPHANUMERIC + ), ), desc=True, ) dimension_numeric_ascending = data_v1beta.OrderBy( dimension=data_v1beta.OrderBy.DimensionOrderBy( dimension_name="audienceId", - order_type=data_v1beta.OrderBy.DimensionOrderBy.OrderType.NUMERIC, + order_type=( + data_v1beta.OrderBy.DimensionOrderBy.OrderType.NUMERIC + ), ), desc=False, ) @@ -316,7 +324,10 @@ def get_order_bys_hints(): @mcp.tool( - title="Retrieves the custom Core Reporting dimensions and metrics for a specific property" + title=( + "Retrieves the custom Core Reporting dimensions and metrics " + "for a specific property" + ) ) async def get_custom_dimensions_and_metrics( property_id: str, @@ -325,40 +336,50 @@ async def get_custom_dimensions_and_metrics( """Returns the property's custom dimensions and metrics. Args: - property_id: The Google Analytics property ID as a string (e.g., "213025502"). - Get property IDs from get_account_summaries(). - include_descriptions: Whether to include user-written descriptions (default: False). - Descriptions can be helpful for understanding custom dimensions/metrics but - increase token usage. + property_id: The Google Analytics property ID as a string + (e.g., "213025502"). Get property IDs from + get_account_summaries(). + include_descriptions: Whether to include user-written + descriptions (default: False). Descriptions can be helpful + for understanding custom dimensions/metrics but increase + token usage. """ metadata = await create_data_api_client().get_metadata( name=f"{construct_property_rn(property_id)}/metadata" ) - + custom_metrics = [ { "api_name": metric.api_name, "display_name": metric.ui_name, "scope": metric.category, "type": metric.type_.name if metric.type_ else "STANDARD", - **({"description": metric.description} if include_descriptions and metric.description else {}) + **( + {"description": metric.description} + if include_descriptions and metric.description + else {} + ), } for metric in metadata.metrics if metric.custom_definition ] - + custom_dimensions = [ { "api_name": dimension.api_name, "display_name": dimension.ui_name, "scope": dimension.category, - **({"description": dimension.description} if include_descriptions and dimension.description else {}) + **( + {"description": dimension.description} + if include_descriptions and dimension.description + else {} + ), } for dimension in metadata.dimensions if dimension.custom_definition ] - + return { "dimensions": custom_dimensions, "metrics": custom_metrics, diff --git a/analytics_mcp/tools/reporting/realtime.py b/analytics_mcp/tools/reporting/realtime.py index 766010d..1b8d7f9 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -16,20 +16,16 @@ from typing import Any, Dict, List -from analytics_mcp.coordinator import mcp -from analytics_mcp.tools.utils import ( - construct_property_rn, - create_data_api_client, - proto_to_dict, -) -from analytics_mcp.tools.reporting.metadata import ( - get_date_ranges_hints, - get_dimension_filter_hints, - get_metric_filter_hints, - get_order_bys_hints, -) from google.analytics import data_v1beta +from analytics_mcp.coordinator import mcp +from analytics_mcp.tools.reporting.metadata import (get_date_ranges_hints, + get_dimension_filter_hints, + get_metric_filter_hints, + get_order_bys_hints) +from analytics_mcp.tools.utils import (construct_property_rn, + create_data_api_client, proto_to_dict) + def _run_realtime_report_description() -> str: """Returns the description for the `run_realtime_report` tool.""" @@ -46,18 +42,21 @@ def _run_realtime_report_description() -> str: The `dimensions` list must consist solely of either of the following: 1. Realtime standard dimensions defined in the HTML table at - https://developers.google.com/analytics/devguides/reporting/data/v1/realtime-api-schema#dimensions. + https://developers.google.com/analytics/devguides/ + reporting/data/v1/realtime-api-schema#dimensions. These dimensions are available to *every* property. - 2. User-scoped custom dimensions for the `property_id`. Use the - `get_custom_dimensions_and_metrics` tool to retrieve the list of - custom dimensions for a property, and look for the custom - dimensions with an `apiName` that begins with "customUser:". + 2. User-scoped custom dimensions for the `property_id`. Use + the `get_custom_dimensions_and_metrics` tool to retrieve + the list of custom dimensions for a property, and look for + the custom dimensions with an `apiName` that begins with + "customUser:". ### Hints for `metrics` - The `metrics` list must consist solely of the Realtime standard - metrics defined in the HTML table at - https://developers.google.com/analytics/devguides/reporting/data/v1/realtime-api-schema#metrics. + The `metrics` list must consist solely of the Realtime + standard metrics defined in the HTML table at + https://developers.google.com/analytics/devguides/ + reporting/data/v1/realtime-api-schema#metrics. These metrics are available to *every* property. Realtime reports can't use custom metrics. @@ -90,39 +89,47 @@ async def run_realtime_report( """Runs a Google Analytics Data API realtime report. See - https://developers.google.com/analytics/devguides/reporting/data/v1/realtime-basics + https://developers.google.com/analytics/devguides/reporting/data/ + v1/realtime-basics for more information. Args: - property_id: The Google Analytics property ID as a string (e.g., "213025502"). - Get property IDs from get_account_summaries(). - dimensions: A list of dimensions to include in the report. Dimensions must be realtime dimensions. - metrics: A list of metrics to include in the report. Metrics must be realtime metrics. + property_id: The Google Analytics property ID as a string + (e.g., "213025502"). Get property IDs from + get_account_summaries(). + dimensions: A list of dimensions to include in the report. + Dimensions must be realtime dimensions. + metrics: A list of metrics to include in the report. Metrics + must be realtime metrics. dimension_filter: A Data API FilterExpression - (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression) - to apply to the dimensions. Don't use this for filtering metrics. Use - metric_filter instead. The `field_name` in a `dimension_filter` must - be a dimension, as defined in the `get_standard_dimensions` and - `get_dimensions` tools. - For more information about the expected format of this argument, see - the `run_report_dimension_filter_hints` tool. + (https://developers.google.com/analytics/devguides/ + reporting/data/v1/rest/v1beta/FilterExpression) + to apply to the dimensions. Don't use this for filtering + metrics. Use metric_filter instead. The `field_name` in a + `dimension_filter` must be a dimension, as defined in the + `get_standard_dimensions` and `get_dimensions` tools. + For more information about the expected format of this + argument, see the `run_report_dimension_filter_hints` tool. metric_filter: A Data API FilterExpression - (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression) - to apply to the metrics. Don't use this for filtering dimensions. Use - dimension_filter instead. The `field_name` in a `metric_filter` must - be a metric, as defined in the `get_standard_metrics` and - `get_metrics` tools. - For more information about the expected format of this argument, see - the `run_report_metric_filter_hints` tool. + (https://developers.google.com/analytics/devguides/ + reporting/data/v1/rest/v1beta/FilterExpression) + to apply to the metrics. Don't use this for filtering + dimensions. Use dimension_filter instead. The `field_name` + in a `metric_filter` must be a metric, as defined in the + `get_standard_metrics` and `get_metrics` tools. + For more information about the expected format of this + argument, see the `run_report_metric_filter_hints` tool. order_bys: A list of Data API OrderBy - (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/OrderBy) + (https://developers.google.com/analytics/devguides/ + reporting/data/v1/rest/v1beta/OrderBy) objects to apply to the dimensions and metrics. - For more information about the expected format of this argument, see - the `run_report_order_bys_hints` tool. - limit: The maximum number of rows to return. Value must be a positive - integer <= 100,000. Default is 100. If unspecified by the API, it returns - up to 10,000 rows. - return_property_quota: Whether to return realtime property quota in the response. + For more information about the expected format of this + argument, see the `run_report_order_bys_hints` tool. + limit: The maximum number of rows to return. Value must be a + positive integer <= 100,000. Default is 100. If unspecified + by the API, it returns up to 10,000 rows. + return_property_quota: Whether to return realtime property + quota in the response. """ # Always request quota to check if we're approaching limits request = data_v1beta.RunRealtimeReportRequest( @@ -168,27 +175,39 @@ async def run_realtime_report( # Include totals/maximums/minimums only if they have data if response.totals: - result["totals"] = [proto_to_dict(total) for total in response.totals] + result["totals"] = [ + proto_to_dict(total) for total in response.totals + ] if response.maximums: - result["maximums"] = [proto_to_dict(maximum) for maximum in response.maximums] + result["maximums"] = [ + proto_to_dict(maximum) for maximum in response.maximums + ] if response.minimums: - result["minimums"] = [proto_to_dict(minimum) for minimum in response.minimums] - + result["minimums"] = [ + proto_to_dict(minimum) for minimum in response.minimums + ] + # Check quota usage and include if >90% used or explicitly requested if response.property_quota: quota_dict = proto_to_dict(response.property_quota) quota_warning = None - + # Check if any quota metric is >90% used for quota_name, quota_info in quota_dict.items(): - if isinstance(quota_info, dict) and "consumed" in quota_info and "remaining" in quota_info: + if ( + isinstance(quota_info, dict) + and "consumed" in quota_info + and "remaining" in quota_info + ): consumed = quota_info.get("consumed", 0) remaining = quota_info.get("remaining", 0) total = consumed + remaining if total > 0 and (consumed / total) > 0.9: quota_warning = ( - f"WARNING: {quota_name} is at {(consumed / total * 100):.1f}% " - f"({consumed}/{total}). Approaching quota limit." + f"WARNING: {quota_name} is at " + f"{(consumed / total * 100):.1f}% " + f"({consumed}/{total}). " + f"Approaching quota limit." ) break @@ -201,10 +220,11 @@ async def run_realtime_report( return result -# The `run_realtime_report` tool requires a more complex description that's generated at -# runtime. Uses the `add_tool` method instead of an annnotation since `add_tool` -# provides the flexibility needed to generate the description while also -# including the `run_realtime_report` method's docstring. +# The `run_realtime_report` tool requires a more complex description +# that's generated at runtime. Uses the `add_tool` method instead of an +# annnotation since `add_tool` provides the flexibility needed to +# generate the description while also including the +# `run_realtime_report` method's docstring. mcp.add_tool( run_realtime_report, title="Run a Google Analytics realtime report using the Data API", diff --git a/analytics_mcp/tools/utils.py b/analytics_mcp/tools/utils.py index 5a9b42f..16de099 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -14,13 +14,13 @@ """Common utilities used by the MCP server.""" +from importlib import metadata from typing import Any, Dict -from google.analytics import admin_v1beta, data_v1beta -from google.api_core.gapic_v1.client_info import ClientInfo -from importlib import metadata import google.auth import proto +from google.analytics import admin_v1beta, data_v1beta +from google.api_core.gapic_v1.client_info import ClientInfo def _get_package_version_with_fallback(): diff --git a/tests/quota_test.py b/tests/quota_test.py index b154a5a..cce1b07 100644 --- a/tests/quota_test.py +++ b/tests/quota_test.py @@ -51,61 +51,85 @@ def _create_mock_response(self, quota_consumed, quota_remaining): return mock_response, mock_quota_dict - @patch('analytics_mcp.tools.reporting.core.create_data_api_client') - @patch('analytics_mcp.tools.reporting.core.construct_property_rn') - @patch('analytics_mcp.tools.reporting.core.proto_to_dict') + @patch("analytics_mcp.tools.reporting.core.create_data_api_client") + @patch("analytics_mcp.tools.reporting.core.construct_property_rn") + @patch("analytics_mcp.tools.reporting.core.proto_to_dict") async def test_quota_warning_not_triggered_low_usage( self, mock_proto_to_dict, mock_construct_rn, mock_client ): - """Tests that quota warning is NOT triggered when usage is low (<90%).""" + """Tests that quota warning is NOT triggered when usage is low + (<90%). + """ # Setup mocks for low usage (1%) mock_construct_rn.return_value = "properties/12345" mock_response, mock_quota_dict = self._create_mock_response( quota_consumed=100, quota_remaining=9900 ) - mock_client.return_value.run_report = AsyncMock(return_value=mock_response) + mock_client.return_value.run_report = AsyncMock( + return_value=mock_response + ) mock_proto_to_dict.return_value = mock_quota_dict - + # Call run_report with return_property_quota=False result = await core.run_report( property_id="12345", - date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], + date_ranges=[ + {"start_date": "yesterday", "end_date": "yesterday"} + ], dimensions=["country"], metrics=["sessions"], - return_property_quota=False + return_property_quota=False, ) - + # Verify quota is NOT included (usage < 90%) - self.assertNotIn("quota", result, "Quota should not be included when usage < 90%") - self.assertNotIn("quota_warning", result, "No warning should be present when usage < 90%") + self.assertNotIn( + "quota", result, "Quota should not be included when usage < 90%" + ) + self.assertNotIn( + "quota_warning", + result, + "No warning should be present when usage < 90%", + ) - @patch('analytics_mcp.tools.reporting.core.create_data_api_client') - @patch('analytics_mcp.tools.reporting.core.construct_property_rn') - @patch('analytics_mcp.tools.reporting.core.proto_to_dict') + @patch("analytics_mcp.tools.reporting.core.create_data_api_client") + @patch("analytics_mcp.tools.reporting.core.construct_property_rn") + @patch("analytics_mcp.tools.reporting.core.proto_to_dict") async def test_quota_warning_triggered_high_usage( self, mock_proto_to_dict, mock_construct_rn, mock_client ): - """Tests that quota warning IS triggered when usage is high (>90%).""" + """Tests that quota warning IS triggered when usage is high + (>90%). + """ # Setup mocks for high usage (91%) mock_construct_rn.return_value = "properties/12345" mock_response, mock_quota_dict = self._create_mock_response( quota_consumed=18200, quota_remaining=1800 ) - mock_client.return_value.run_report = AsyncMock(return_value=mock_response) + mock_client.return_value.run_report = AsyncMock( + return_value=mock_response + ) mock_proto_to_dict.return_value = mock_quota_dict - + # Call run_report with return_property_quota=False result = await core.run_report( property_id="12345", - date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], + date_ranges=[ + {"start_date": "yesterday", "end_date": "yesterday"} + ], dimensions=["country"], metrics=["sessions"], - return_property_quota=False + return_property_quota=False, ) - + # Verify quota IS included (usage > 90%) - self.assertIn("quota", result, "Quota should be included when usage > 90%") - self.assertIn("quota_warning", result, "Warning should be present when usage > 90%") + self.assertIn( + "quota", result, "Quota should be included when usage > 90%" + ) + self.assertIn( + "quota_warning", + result, + "Warning should be present when usage > 90%", + ) # Verify warning message format warning = result["quota_warning"] @@ -114,64 +138,82 @@ async def test_quota_warning_triggered_high_usage( self.assertIn("91.0%", warning) self.assertIn("18200/20000", warning) - @patch('analytics_mcp.tools.reporting.core.create_data_api_client') - @patch('analytics_mcp.tools.reporting.core.construct_property_rn') - @patch('analytics_mcp.tools.reporting.core.proto_to_dict') + @patch("analytics_mcp.tools.reporting.core.create_data_api_client") + @patch("analytics_mcp.tools.reporting.core.construct_property_rn") + @patch("analytics_mcp.tools.reporting.core.proto_to_dict") async def test_quota_warning_edge_case_exactly_90_percent( self, mock_proto_to_dict, mock_construct_rn, mock_client ): - """Tests that quota warning is NOT triggered at exactly 90% (threshold is >90%).""" + """Tests that quota warning is NOT triggered at exactly 90% + (threshold is >90%). + """ # Setup mocks for exactly 90% usage mock_construct_rn.return_value = "properties/12345" mock_response, mock_quota_dict = self._create_mock_response( quota_consumed=9000, quota_remaining=1000 ) - mock_client.return_value.run_report = AsyncMock(return_value=mock_response) + mock_client.return_value.run_report = AsyncMock( + return_value=mock_response + ) mock_proto_to_dict.return_value = mock_quota_dict - + # Call run_report with return_property_quota=False result = await core.run_report( property_id="12345", - date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], + date_ranges=[ + {"start_date": "yesterday", "end_date": "yesterday"} + ], dimensions=["country"], metrics=["sessions"], - return_property_quota=False + return_property_quota=False, ) - + # Verify quota is NOT included (exactly 90% is not > 90%) - self.assertNotIn("quota", result, "Quota should not be included at exactly 90%") + self.assertNotIn( + "quota", result, "Quota should not be included at exactly 90%" + ) self.assertNotIn("quota_warning", result, "No warning at exactly 90%") - @patch('analytics_mcp.tools.reporting.core.create_data_api_client') - @patch('analytics_mcp.tools.reporting.core.construct_property_rn') - @patch('analytics_mcp.tools.reporting.core.proto_to_dict') + @patch("analytics_mcp.tools.reporting.core.create_data_api_client") + @patch("analytics_mcp.tools.reporting.core.construct_property_rn") + @patch("analytics_mcp.tools.reporting.core.proto_to_dict") async def test_quota_included_when_explicitly_requested( self, mock_proto_to_dict, mock_construct_rn, mock_client ): - """Tests that quota is always included when explicitly requested.""" + """Tests that quota is always included when explicitly + requested. + """ # Setup mocks for low usage mock_construct_rn.return_value = "properties/12345" mock_response, mock_quota_dict = self._create_mock_response( quota_consumed=10, quota_remaining=19990 ) - mock_client.return_value.run_report = AsyncMock(return_value=mock_response) + mock_client.return_value.run_report = AsyncMock( + return_value=mock_response + ) mock_proto_to_dict.return_value = mock_quota_dict - + # Call run_report with return_property_quota=True result = await core.run_report( property_id="12345", - date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], + date_ranges=[ + {"start_date": "yesterday", "end_date": "yesterday"} + ], dimensions=["country"], metrics=["sessions"], - return_property_quota=True + return_property_quota=True, ) - + # Verify quota IS included even with low usage - self.assertIn("quota", result, "Quota should be included when explicitly requested") + self.assertIn( + "quota", + result, + "Quota should be included when explicitly requested", + ) - @patch('analytics_mcp.tools.reporting.realtime.create_data_api_client') - @patch('analytics_mcp.tools.reporting.realtime.construct_property_rn') - @patch('analytics_mcp.tools.reporting.realtime.proto_to_dict') + @patch("analytics_mcp.tools.reporting.realtime.create_data_api_client") + @patch("analytics_mcp.tools.reporting.realtime.construct_property_rn") + @patch("analytics_mcp.tools.reporting.realtime.proto_to_dict") async def test_realtime_quota_warning_triggered( self, mock_proto_to_dict, mock_construct_rn, mock_client ): @@ -181,33 +223,45 @@ async def test_realtime_quota_warning_triggered( mock_response, mock_quota_dict = self._create_mock_response( quota_consumed=19000, quota_remaining=1000 ) - mock_client.return_value.run_realtime_report = AsyncMock(return_value=mock_response) + mock_client.return_value.run_realtime_report = AsyncMock( + return_value=mock_response + ) mock_proto_to_dict.return_value = mock_quota_dict - + # Call run_realtime_report with return_property_quota=False result = await realtime.run_realtime_report( property_id="12345", dimensions=["country"], metrics=["activeUsers"], - return_property_quota=False + return_property_quota=False, ) - + # Verify quota warning for realtime - self.assertIn("quota", result, "Quota should be included for realtime when usage > 90%") - self.assertIn("quota_warning", result, "Warning should be present for realtime") + self.assertIn( + "quota", + result, + "Quota should be included for realtime when usage > 90%", + ) + self.assertIn( + "quota_warning", + result, + "Warning should be present for realtime", + ) # Verify warning format warning = result["quota_warning"] self.assertIn("WARNING", warning) self.assertIn("95.0%", warning) - @patch('analytics_mcp.tools.reporting.core.create_data_api_client') - @patch('analytics_mcp.tools.reporting.core.construct_property_rn') - @patch('analytics_mcp.tools.reporting.core.proto_to_dict') + @patch("analytics_mcp.tools.reporting.core.create_data_api_client") + @patch("analytics_mcp.tools.reporting.core.construct_property_rn") + @patch("analytics_mcp.tools.reporting.core.proto_to_dict") async def test_quota_warning_checks_multiple_metrics( self, mock_proto_to_dict, mock_construct_rn, mock_client ): - """Tests that quota warning checks all quota metrics and triggers on first >90%.""" + """Tests that quota warning checks all quota metrics and + triggers on first >90%. + """ # Setup mocks with tokens_per_day OK but tokens_per_hour high mock_construct_rn.return_value = "properties/12345" mock_response = MagicMock() @@ -219,28 +273,26 @@ async def test_quota_warning_checks_multiple_metrics( mock_response.totals = None mock_response.maximums = None mock_response.minimums = None - + mock_quota_dict = { - "tokens_per_day": { - "consumed": 100, - "remaining": 199900 - }, - "tokens_per_hour": { - "consumed": 36400, - "remaining": 3600 - } + "tokens_per_day": {"consumed": 100, "remaining": 199900}, + "tokens_per_hour": {"consumed": 36400, "remaining": 3600}, } mock_response.property_quota = MagicMock() - mock_client.return_value.run_report = AsyncMock(return_value=mock_response) + mock_client.return_value.run_report = AsyncMock( + return_value=mock_response + ) mock_proto_to_dict.return_value = mock_quota_dict - + # Call run_report result = await core.run_report( property_id="12345", - date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], + date_ranges=[ + {"start_date": "yesterday", "end_date": "yesterday"} + ], dimensions=["country"], metrics=["sessions"], - return_property_quota=False + return_property_quota=False, ) # Verify warning is triggered by tokens_per_hour diff --git a/tests/utils_test.py b/tests/utils_test.py index e1521a1..903b1d6 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -32,7 +32,10 @@ def test_construct_property_rn(self): self.assertEqual( utils.construct_property_rn(" 12345 "), "properties/12345", - "Whitespace around property ID should be trimmed and considered valid", + ( + "Whitespace around property ID should be trimmed " + "and considered valid" + ), ) self.assertEqual( utils.construct_property_rn("213025502"), From d27efb52b606deb4a381a5420b1761ae82fb6d11 Mon Sep 17 00:00:00 2001 From: Adam Gustavsson Date: Fri, 3 Oct 2025 08:01:26 +0200 Subject: [PATCH 3/5] Run formatting to satisfy CI --- analytics_mcp/server.py | 1 + analytics_mcp/tools/admin/info.py | 7 +++++-- analytics_mcp/tools/reporting/core.py | 17 +++++++++++------ analytics_mcp/tools/reporting/metadata.py | 9 ++++++--- analytics_mcp/tools/reporting/realtime.py | 17 +++++++++++------ noxfile.py | 3 ++- 6 files changed, 36 insertions(+), 18 deletions(-) diff --git a/analytics_mcp/server.py b/analytics_mcp/server.py index 5205047..cd051a4 100755 --- a/analytics_mcp/server.py +++ b/analytics_mcp/server.py @@ -17,6 +17,7 @@ """Entry point for the Google Analytics MCP server.""" from analytics_mcp.coordinator import mcp + # The following imports are necessary to register the tools with the `mcp` # object, even though they are not directly used in this file. # The `# noqa: F401` comment tells the linter to ignore the "unused import" diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index 795498a..020a6dd 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -19,8 +19,11 @@ from google.analytics import admin_v1beta from analytics_mcp.coordinator import mcp -from analytics_mcp.tools.utils import (construct_property_rn, - create_admin_api_client, proto_to_dict) +from analytics_mcp.tools.utils import ( + construct_property_rn, + create_admin_api_client, + proto_to_dict, +) @mcp.tool() diff --git a/analytics_mcp/tools/reporting/core.py b/analytics_mcp/tools/reporting/core.py index b48f46d..60847c4 100644 --- a/analytics_mcp/tools/reporting/core.py +++ b/analytics_mcp/tools/reporting/core.py @@ -19,12 +19,17 @@ from google.analytics import data_v1beta from analytics_mcp.coordinator import mcp -from analytics_mcp.tools.reporting.metadata import (get_date_ranges_hints, - get_dimension_filter_hints, - get_metric_filter_hints, - get_order_bys_hints) -from analytics_mcp.tools.utils import (construct_property_rn, - create_data_api_client, proto_to_dict) +from analytics_mcp.tools.reporting.metadata import ( + get_date_ranges_hints, + get_dimension_filter_hints, + get_metric_filter_hints, + get_order_bys_hints, +) +from analytics_mcp.tools.utils import ( + construct_property_rn, + create_data_api_client, + proto_to_dict, +) def _run_report_description() -> str: diff --git a/analytics_mcp/tools/reporting/metadata.py b/analytics_mcp/tools/reporting/metadata.py index 4db9d77..bcd1d53 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -19,9 +19,12 @@ from google.analytics import data_v1beta from analytics_mcp.coordinator import mcp -from analytics_mcp.tools.utils import (construct_property_rn, - create_data_api_client, proto_to_dict, - proto_to_json) +from analytics_mcp.tools.utils import ( + construct_property_rn, + create_data_api_client, + proto_to_dict, + proto_to_json, +) def get_date_ranges_hints(): diff --git a/analytics_mcp/tools/reporting/realtime.py b/analytics_mcp/tools/reporting/realtime.py index 1b8d7f9..64dadaa 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -19,12 +19,17 @@ from google.analytics import data_v1beta from analytics_mcp.coordinator import mcp -from analytics_mcp.tools.reporting.metadata import (get_date_ranges_hints, - get_dimension_filter_hints, - get_metric_filter_hints, - get_order_bys_hints) -from analytics_mcp.tools.utils import (construct_property_rn, - create_data_api_client, proto_to_dict) +from analytics_mcp.tools.reporting.metadata import ( + get_date_ranges_hints, + get_dimension_filter_hints, + get_metric_filter_hints, + get_order_bys_hints, +) +from analytics_mcp.tools.utils import ( + construct_property_rn, + create_data_api_client, + proto_to_dict, +) def _run_realtime_report_description() -> str: diff --git a/noxfile.py b/noxfile.py index e683176..2c97172 100644 --- a/noxfile.py +++ b/noxfile.py @@ -12,10 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -import nox import os import pathlib +import nox + PYTHON_VERSIONS = ["3.10", "3.11", "3.12", "3.13"] TEST_COMMAND = [ From f4099db9f6215a99062dfbba66beb846c961b565 Mon Sep 17 00:00:00 2001 From: Adam Gustavsson Date: Fri, 3 Oct 2025 08:34:13 +0200 Subject: [PATCH 4/5] restored the noxfile --- noxfile.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index 2c97172..e683176 100644 --- a/noxfile.py +++ b/noxfile.py @@ -12,11 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +import nox import os import pathlib -import nox - PYTHON_VERSIONS = ["3.10", "3.11", "3.12", "3.13"] TEST_COMMAND = [ From 0491c2f01b6e6b6e9d6448b5ab4aa1b65b95c19f Mon Sep 17 00:00:00 2001 From: Adam Gustavsson Date: Fri, 3 Oct 2025 15:47:08 +0200 Subject: [PATCH 5/5] Apply Black (l=80) and isort --- analytics_mcp/tools/admin/info.py | 8 ++--- analytics_mcp/tools/reporting/core.py | 31 ++++++++++--------- analytics_mcp/tools/reporting/metadata.py | 4 +-- analytics_mcp/tools/reporting/realtime.py | 30 +++++++++--------- analytics_mcp/tools/utils.py | 6 ++-- noxfile.py | 3 +- tests/quota_test.py | 37 ++++++++--------------- tests/utils_test.py | 6 ++-- 8 files changed, 55 insertions(+), 70 deletions(-) diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index 020a6dd..db125d1 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -34,9 +34,7 @@ async def get_account_summaries() -> List[Dict[str, Any]]: # 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() - ) + summary_pager = await create_admin_api_client().list_account_summaries() summaries = [] async for summary_page in summary_pager: @@ -94,9 +92,9 @@ async def get_property_details(property_id: str) -> Dict[str, Any]: name=construct_property_rn(property_id) ) response = await client.get_property(request=request) - + # Convert to dict and remove redundant parent field result = proto_to_dict(response) result.pop("parent", None) - + return result diff --git a/analytics_mcp/tools/reporting/core.py b/analytics_mcp/tools/reporting/core.py index 60847c4..525af2a 100644 --- a/analytics_mcp/tools/reporting/core.py +++ b/analytics_mcp/tools/reporting/core.py @@ -189,15 +189,19 @@ async def run_report( "row_count": response.row_count, "dimension_headers": [h.name for h in response.dimension_headers], "metric_headers": [h.name for h in response.metric_headers], - "rows": [ - { - "dimensions": [dv.value for dv in row.dimension_values], - "metrics": [mv.value for mv in row.metric_values] - } - for row in response.rows - ] if response.rows else [] + "rows": ( + [ + { + "dimensions": [dv.value for dv in row.dimension_values], + "metrics": [mv.value for mv in row.metric_values], + } + for row in response.rows + ] + if response.rows + else [] + ), } - + # Include metadata (exclude empty/false values) if response.metadata: metadata = {} @@ -209,17 +213,14 @@ async def run_report( metadata["data_loss_from_other_row"] = True if response.metadata.sampling_metadatas: metadata["sampling_metadatas"] = [ - proto_to_dict(sm) - for sm in response.metadata.sampling_metadatas + proto_to_dict(sm) for sm in response.metadata.sampling_metadatas ] if metadata: result["metadata"] = metadata # Include totals/maximums/minimums only if they have data if response.totals: - result["totals"] = [ - proto_to_dict(total) for total in response.totals - ] + result["totals"] = [proto_to_dict(total) for total in response.totals] if response.maximums: result["maximums"] = [ proto_to_dict(maximum) for maximum in response.maximums @@ -252,13 +253,13 @@ async def run_report( f"Approaching quota limit." ) break - + # Include quota if explicitly requested or if usage >90% if return_property_quota or quota_warning: result["quota"] = quota_dict if quota_warning: result["quota_warning"] = quota_warning - + return result diff --git a/analytics_mcp/tools/reporting/metadata.py b/analytics_mcp/tools/reporting/metadata.py index bcd1d53..1439864 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -272,9 +272,7 @@ def get_order_bys_hints(): dimension_numeric_ascending = data_v1beta.OrderBy( dimension=data_v1beta.OrderBy.DimensionOrderBy( dimension_name="audienceId", - order_type=( - data_v1beta.OrderBy.DimensionOrderBy.OrderType.NUMERIC - ), + order_type=(data_v1beta.OrderBy.DimensionOrderBy.OrderType.NUMERIC), ), desc=False, ) diff --git a/analytics_mcp/tools/reporting/realtime.py b/analytics_mcp/tools/reporting/realtime.py index 64dadaa..2dba16e 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -163,26 +163,28 @@ async def run_realtime_report( request.limit = limit response = await create_data_api_client().run_realtime_report(request) - + # Compact format - eliminate repetition result = { "row_count": response.row_count, "dimension_headers": [h.name for h in response.dimension_headers], "metric_headers": [h.name for h in response.metric_headers], - "rows": [ - { - "dimensions": [dv.value for dv in row.dimension_values], - "metrics": [mv.value for mv in row.metric_values] - } - for row in response.rows - ] if response.rows else [] + "rows": ( + [ + { + "dimensions": [dv.value for dv in row.dimension_values], + "metrics": [mv.value for mv in row.metric_values], + } + for row in response.rows + ] + if response.rows + else [] + ), } - + # Include totals/maximums/minimums only if they have data if response.totals: - result["totals"] = [ - proto_to_dict(total) for total in response.totals - ] + result["totals"] = [proto_to_dict(total) for total in response.totals] if response.maximums: result["maximums"] = [ proto_to_dict(maximum) for maximum in response.maximums @@ -215,13 +217,13 @@ async def run_realtime_report( f"Approaching quota limit." ) break - + # Include quota if explicitly requested or if usage >90% if return_property_quota or quota_warning: result["quota"] = quota_dict if quota_warning: result["quota_warning"] = quota_warning - + return result diff --git a/analytics_mcp/tools/utils.py b/analytics_mcp/tools/utils.py index 16de099..d995034 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -73,14 +73,14 @@ def create_data_api_client() -> data_v1beta.BetaAnalyticsDataAsyncClient: def construct_property_rn(property_value: str) -> str: """Returns a property resource name in the format required by APIs. - + Args: property_value: A property ID as a numeric string (e.g., "213025502"). Get property IDs from get_account_summaries(). - + Returns: A property resource name in the format "properties/{property_id}". - + Raises: ValueError: If property_value is not a numeric string. """ diff --git a/noxfile.py b/noxfile.py index e683176..2c97172 100644 --- a/noxfile.py +++ b/noxfile.py @@ -12,10 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -import nox import os import pathlib +import nox + PYTHON_VERSIONS = ["3.10", "3.11", "3.12", "3.13"] TEST_COMMAND = [ diff --git a/tests/quota_test.py b/tests/quota_test.py index cce1b07..40da568 100644 --- a/tests/quota_test.py +++ b/tests/quota_test.py @@ -34,21 +34,18 @@ def _create_mock_response(self, quota_consumed, quota_remaining): mock_response.totals = None mock_response.maximums = None mock_response.minimums = None - + # Create quota mock mock_quota = MagicMock() mock_quota_dict = { "tokens_per_day": { "consumed": quota_consumed, - "remaining": quota_remaining + "remaining": quota_remaining, }, - "tokens_per_hour": { - "consumed": 10, - "remaining": 39990 - } + "tokens_per_hour": {"consumed": 10, "remaining": 39990}, } mock_response.property_quota = mock_quota - + return mock_response, mock_quota_dict @patch("analytics_mcp.tools.reporting.core.create_data_api_client") @@ -73,9 +70,7 @@ async def test_quota_warning_not_triggered_low_usage( # Call run_report with return_property_quota=False result = await core.run_report( property_id="12345", - date_ranges=[ - {"start_date": "yesterday", "end_date": "yesterday"} - ], + date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], dimensions=["country"], metrics=["sessions"], return_property_quota=False, @@ -113,9 +108,7 @@ async def test_quota_warning_triggered_high_usage( # Call run_report with return_property_quota=False result = await core.run_report( property_id="12345", - date_ranges=[ - {"start_date": "yesterday", "end_date": "yesterday"} - ], + date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], dimensions=["country"], metrics=["sessions"], return_property_quota=False, @@ -130,7 +123,7 @@ async def test_quota_warning_triggered_high_usage( result, "Warning should be present when usage > 90%", ) - + # Verify warning message format warning = result["quota_warning"] self.assertIn("WARNING", warning) @@ -160,9 +153,7 @@ async def test_quota_warning_edge_case_exactly_90_percent( # Call run_report with return_property_quota=False result = await core.run_report( property_id="12345", - date_ranges=[ - {"start_date": "yesterday", "end_date": "yesterday"} - ], + date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], dimensions=["country"], metrics=["sessions"], return_property_quota=False, @@ -196,9 +187,7 @@ async def test_quota_included_when_explicitly_requested( # Call run_report with return_property_quota=True result = await core.run_report( property_id="12345", - date_ranges=[ - {"start_date": "yesterday", "end_date": "yesterday"} - ], + date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], dimensions=["country"], metrics=["sessions"], return_property_quota=True, @@ -247,7 +236,7 @@ async def test_realtime_quota_warning_triggered( result, "Warning should be present for realtime", ) - + # Verify warning format warning = result["quota_warning"] self.assertIn("WARNING", warning) @@ -287,14 +276,12 @@ async def test_quota_warning_checks_multiple_metrics( # Call run_report result = await core.run_report( property_id="12345", - date_ranges=[ - {"start_date": "yesterday", "end_date": "yesterday"} - ], + date_ranges=[{"start_date": "yesterday", "end_date": "yesterday"}], dimensions=["country"], metrics=["sessions"], return_property_quota=False, ) - + # Verify warning is triggered by tokens_per_hour self.assertIn("quota_warning", result) self.assertIn("tokens_per_hour", result["quota_warning"]) diff --git a/tests/utils_test.py b/tests/utils_test.py index 903b1d6..c5f4028 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -45,9 +45,7 @@ def test_construct_property_rn(self): def test_construct_property_rn_invalid_input(self): """Tests that construct_property_rn raises a ValueError for invalid input.""" - with self.assertRaises( - ValueError, msg="Empty string should fail" - ): + with self.assertRaises(ValueError, msg="Empty string should fail"): utils.construct_property_rn("") with self.assertRaises( ValueError, msg="Whitespace-only string should fail" @@ -72,4 +70,4 @@ def test_construct_property_rn_invalid_input(self): with self.assertRaises( ValueError, msg="Number with decimal should fail" ): - utils.construct_property_rn("123.45") \ No newline at end of file + utils.construct_property_rn("123.45")