diff --git a/analytics_mcp/server.py b/analytics_mcp/server.py index 234f493..cd051a4 100755 --- a/analytics_mcp/server.py +++ b/analytics_mcp/server.py @@ -23,8 +23,8 @@ # 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 a350d29..db125d1 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -16,36 +16,56 @@ from typing import Any, Dict, List +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 google.analytics import admin_v1beta @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() - 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 +80,21 @@ 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..525af2a 100644 --- a/analytics_mcp/tools/reporting/core.py +++ b/analytics_mcp/tools/reporting/core.py @@ -16,6 +16,8 @@ from typing import Any, Dict, List +from google.analytics import data_v1beta + from analytics_mcp.coordinator import mcp from analytics_mcp.tools.reporting.metadata import ( get_date_ranges_hints, @@ -28,7 +30,6 @@ create_data_api_client, proto_to_dict, ) -from google.analytics import data_v1beta def _run_report_description() -> str: @@ -46,18 +47,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 @@ -80,14 +83,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, @@ -95,49 +98,60 @@ 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. 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) + (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( property=construct_property_rn(property_id), dimensions=[ @@ -145,7 +159,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 +184,83 @@ 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 " + f"{(consumed / total * 100):.1f}% " + f"({consumed}/{total}). " + 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 # 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..1439864 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -16,6 +16,8 @@ from typing import Any, Dict, List +from google.analytics import data_v1beta + from analytics_mcp.coordinator import mcp from analytics_mcp.tools.utils import ( construct_property_rn, @@ -23,7 +25,6 @@ proto_to_dict, proto_to_json, ) -from google.analytics import data_v1beta def get_date_ranges_hints(): @@ -122,7 +123,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 +186,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 +254,25 @@ 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,33 +325,63 @@ 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: 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..2dba16e 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -16,19 +16,20 @@ from typing import Any, Dict, List +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, -) 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.tools.utils import ( + construct_property_rn, + create_data_api_client, + proto_to_dict, +) def _run_realtime_report_description() -> str: @@ -46,18 +47,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. @@ -78,66 +82,68 @@ 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. 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. Accepted formats are: - - A number - - A string consisting of 'properties/' followed by a number - 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 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. - 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( 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,17 +161,77 @@ 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 + ] -# 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. + # 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 " + f"{(consumed / total * 100):.1f}% " + f"({consumed}/{total}). " + 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 + + +# 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 34eec86..d995034 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(): @@ -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/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 new file mode 100644 index 0000000..40da568 --- /dev/null +++ b/tests/quota_test.py @@ -0,0 +1,292 @@ +# 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..c5f4028 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,42 @@ 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"): 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")