Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion analytics_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
55 changes: 40 additions & 15 deletions analytics_mcp/tools/admin/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
174 changes: 132 additions & 42 deletions analytics_mcp/tools/reporting/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,7 +30,6 @@
create_data_api_client,
proto_to_dict,
)
from google.analytics import data_v1beta


def _run_report_description() -> str:
Expand All @@ -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
Expand All @@ -80,72 +83,83 @@ 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,
) -> Dict[str, Any]:
"""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=[
data_v1beta.Dimension(name=dimension) for dimension in dimensions
],
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:
Expand All @@ -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
Expand Down
Loading