From 7bb0b2c28d8db370f9235c488a9f412ee8696464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 11 Jun 2026 12:14:08 -0400 Subject: [PATCH 1/9] feat: add list_key_events tool Lists the key events (conversions) configured for a GA4 property via the Admin API properties.keyEvents.list method. Read-only; fits the existing analytics.readonly scope. --- analytics_mcp/coordinator.py | 2 + analytics_mcp/tools/admin/info.py | 25 +++++++++++ tests/info_test.py | 71 +++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 tests/info_test.py diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index b53516d..dfc7459 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -33,6 +33,7 @@ get_account_summaries, list_google_ads_links, get_property_details, + list_key_events, list_property_annotations, ) from analytics_mcp.tools.reporting.core import ( @@ -75,6 +76,7 @@ FunctionTool(get_account_summaries), FunctionTool(list_google_ads_links), FunctionTool(get_property_details), + FunctionTool(list_key_events), FunctionTool(list_property_annotations), FunctionTool(get_custom_dimensions_and_metrics), run_report_with_description, diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index 04cc3ff..1a2acc4 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -78,6 +78,31 @@ def _sync_call(): return proto_to_dict(response) +async def list_key_events(property_id: int | str) -> List[Dict[str, Any]]: + """Returns the key events configured for a property. + + Key events (formerly known as conversion events) are the events that a + property has marked as most important, such as purchases or sign-ups. + Reports use key events to calculate conversion-related metrics. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1beta.ListKeyEventsRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + key_events_pager = create_admin_api_client().list_key_events( + request=request + ) + return [proto_to_dict(key_event) for key_event in key_events_pager] + + return await asyncio.to_thread(_sync_call) + + async def list_property_annotations( property_id: int | str, ) -> List[Dict[str, Any]]: diff --git a/tests/info_test.py b/tests/info_test.py new file mode 100644 index 0000000..5e0382d --- /dev/null +++ b/tests/info_test.py @@ -0,0 +1,71 @@ +# 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 the admin info tools.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import admin_v1beta + +from analytics_mcp.tools.admin.info import list_key_events + + +class TestListKeyEvents(unittest.TestCase): + """Test cases for list_key_events.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_key_events(self, mock_create_client): + """Tests that key events are listed and converted to dicts.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + key_event = admin_v1beta.KeyEvent( + name="properties/12345/keyEvents/67890", + event_name="purchase", + counting_method=( + admin_v1beta.KeyEvent.CountingMethod.ONCE_PER_EVENT + ), + ) + mock_client.list_key_events.return_value = [key_event] + + result = asyncio.run(list_key_events(12345)) + + request = mock_client.list_key_events.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["event_name"], "purchase") + self.assertEqual(result[0]["name"], "properties/12345/keyEvents/67890") + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_accepts_property_rn_string(self, mock_create_client): + """Tests that a 'properties/' prefixed string is accepted.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.list_key_events.return_value = [] + + result = asyncio.run(list_key_events("properties/9876")) + + request = mock_client.list_key_events.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/9876") + self.assertEqual(result, []) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_key_events("not-a-property")) + + +if __name__ == "__main__": + unittest.main() From c65b905dc6998444e78095dabeb6037c2c9d8cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 11 Jun 2026 12:14:49 -0400 Subject: [PATCH 2/9] feat: add list_data_streams tool Lists the data streams (web, Android, iOS) configured for a GA4 property via the Admin API properties.dataStreams.list method, including platform-specific details such as web measurement IDs. Read-only; fits the existing analytics.readonly scope. --- analytics_mcp/coordinator.py | 2 ++ analytics_mcp/tools/admin/info.py | 27 +++++++++++++++++++++ tests/info_test.py | 40 ++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index dfc7459..003de60 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -33,6 +33,7 @@ get_account_summaries, list_google_ads_links, get_property_details, + list_data_streams, list_key_events, list_property_annotations, ) @@ -76,6 +77,7 @@ FunctionTool(get_account_summaries), FunctionTool(list_google_ads_links), FunctionTool(get_property_details), + FunctionTool(list_data_streams), FunctionTool(list_key_events), FunctionTool(list_property_annotations), FunctionTool(get_custom_dimensions_and_metrics), diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index 1a2acc4..ba698e2 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -103,6 +103,33 @@ def _sync_call(): return await asyncio.to_thread(_sync_call) +async def list_data_streams(property_id: int | str) -> List[Dict[str, Any]]: + """Returns the data streams configured for a property. + + Data streams are the sources that send data to a property, such as a + web site, an Android app, or an iOS app. Each stream includes its + platform-specific details, e.g. the measurement ID for a web stream. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1beta.ListDataStreamsRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + data_streams_pager = create_admin_api_client().list_data_streams( + request=request + ) + return [ + proto_to_dict(data_stream) for data_stream in data_streams_pager + ] + + return await asyncio.to_thread(_sync_call) + + async def list_property_annotations( property_id: int | str, ) -> List[Dict[str, Any]]: diff --git a/tests/info_test.py b/tests/info_test.py index 5e0382d..54d36cc 100644 --- a/tests/info_test.py +++ b/tests/info_test.py @@ -20,7 +20,10 @@ from google.analytics import admin_v1beta -from analytics_mcp.tools.admin.info import list_key_events +from analytics_mcp.tools.admin.info import ( + list_data_streams, + list_key_events, +) class TestListKeyEvents(unittest.TestCase): @@ -67,5 +70,40 @@ def test_invalid_property_id_raises(self): asyncio.run(list_key_events("not-a-property")) +class TestListDataStreams(unittest.TestCase): + """Test cases for list_data_streams.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_data_streams(self, mock_create_client): + """Tests that data streams are listed and converted to dicts.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + web_stream = admin_v1beta.DataStream( + name="properties/12345/dataStreams/111", + type_=admin_v1beta.DataStream.DataStreamType.WEB_DATA_STREAM, + display_name="Web stream", + web_stream_data=admin_v1beta.DataStream.WebStreamData( + measurement_id="G-ABC123", + default_uri="https://www.example.com", + ), + ) + mock_client.list_data_streams.return_value = [web_stream] + + result = asyncio.run(list_data_streams(12345)) + + request = mock_client.list_data_streams.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["display_name"], "Web stream") + self.assertEqual( + result[0]["web_stream_data"]["measurement_id"], "G-ABC123" + ) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_data_streams("bogus")) + + if __name__ == "__main__": unittest.main() From f2b8fee81b9e3a32752bd9a3114ffe5387d8fcd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 11 Jun 2026 12:15:42 -0400 Subject: [PATCH 3/9] feat: add get_metadata tool for the full dimension and metric catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the complete properties.getMetadata response — standard and custom dimensions and metrics with descriptions, categories, and deprecation status — so an LLM can discover available fields before building a report. The existing get_custom_dimensions_and_metrics tool is unchanged and still returns only custom definitions. --- analytics_mcp/coordinator.py | 2 + analytics_mcp/tools/reporting/metadata.py | 27 ++++++ tests/metadata_test.py | 109 ++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 tests/metadata_test.py diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index 003de60..d7b0680 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -47,6 +47,7 @@ ) from analytics_mcp.tools.reporting.metadata import ( get_custom_dimensions_and_metrics, + get_metadata, ) from analytics_mcp.tools.reporting.funnel import ( run_funnel_report, @@ -81,6 +82,7 @@ FunctionTool(list_key_events), FunctionTool(list_property_annotations), FunctionTool(get_custom_dimensions_and_metrics), + FunctionTool(get_metadata), run_report_with_description, run_realtime_report_with_description, run_funnel_report_with_description, diff --git a/analytics_mcp/tools/reporting/metadata.py b/analytics_mcp/tools/reporting/metadata.py index af8263c..5df8f5f 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -474,6 +474,33 @@ def get_order_bys_hints(): """ +async def get_metadata(property_id: int | str) -> Dict[str, Any]: + """Returns the full dimension and metric catalog for a property. + + Includes every dimension and metric available in reports for the + property — both standard and custom — with API names, display names, + descriptions, category groupings, and deprecation status. Use this + to discover what fields are available before building a report. + + If you only need the property's custom definitions, use the + `get_custom_dimensions_and_metrics` tool instead, since its response + is much smaller. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + + def _sync_call(): + return create_data_api_client().get_metadata( + name=f"{construct_property_rn(property_id)}/metadata" + ) + + metadata = await asyncio.to_thread(_sync_call) + return proto_to_dict(metadata) + + async def get_custom_dimensions_and_metrics( property_id: int | str, ) -> Dict[str, List[Dict[str, Any]]]: diff --git a/tests/metadata_test.py b/tests/metadata_test.py new file mode 100644 index 0000000..4adb807 --- /dev/null +++ b/tests/metadata_test.py @@ -0,0 +1,109 @@ +# 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 the reporting metadata tools.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1beta + +from analytics_mcp.tools.reporting.metadata import ( + get_custom_dimensions_and_metrics, + get_metadata, +) + + +def _sample_metadata() -> data_v1beta.Metadata: + """Returns a Metadata proto with standard and custom entries.""" + return data_v1beta.Metadata( + name="properties/12345/metadata", + dimensions=[ + data_v1beta.DimensionMetadata( + api_name="country", + ui_name="Country", + description="The country from which activity originated.", + category="Geography", + custom_definition=False, + ), + data_v1beta.DimensionMetadata( + api_name="customEvent:plan_type", + ui_name="Plan type", + description="Custom event-scoped dimension.", + category="Custom", + custom_definition=True, + ), + ], + metrics=[ + data_v1beta.MetricMetadata( + api_name="activeUsers", + ui_name="Active users", + description="The number of distinct active users.", + category="User", + custom_definition=False, + ), + ], + ) + + +class TestGetMetadata(unittest.TestCase): + """Test cases for get_metadata.""" + + @patch("analytics_mcp.tools.reporting.metadata.create_data_api_client") + def test_returns_full_catalog(self, mock_create_client): + """Tests that the full catalog is returned, not just custom.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.get_metadata.return_value = _sample_metadata() + + result = asyncio.run(get_metadata(12345)) + + mock_client.get_metadata.assert_called_once_with( + name="properties/12345/metadata" + ) + dimension_names = [d["api_name"] for d in result["dimensions"]] + self.assertIn("country", dimension_names) + self.assertIn("customEvent:plan_type", dimension_names) + self.assertEqual(result["metrics"][0]["api_name"], "activeUsers") + self.assertEqual(result["dimensions"][0]["category"], "Geography") + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(get_metadata("bogus")) + + +class TestGetCustomDimensionsAndMetrics(unittest.TestCase): + """Test cases for get_custom_dimensions_and_metrics.""" + + @patch("analytics_mcp.tools.reporting.metadata.create_data_api_client") + def test_returns_only_custom_definitions(self, mock_create_client): + """Tests that only custom definitions are returned.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.get_metadata.return_value = _sample_metadata() + + result = asyncio.run(get_custom_dimensions_and_metrics(12345)) + + self.assertEqual(len(result["custom_dimensions"]), 1) + self.assertEqual( + result["custom_dimensions"][0]["api_name"], + "customEvent:plan_type", + ) + self.assertEqual(result["custom_metrics"], []) + + +if __name__ == "__main__": + unittest.main() From 726a1d45a8a081c4086a11727682d8d4b1604b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 11 Jun 2026 12:16:57 -0400 Subject: [PATCH 4/9] feat: add list_properties tool Lists the GA4 properties under an account via the Admin API properties.list method with a parent account filter. Returns full property objects (display name, industry, time zone, currency, service level), complementing the summary info available from get_account_summaries. Adds a construct_account_rn helper to utils mirroring construct_property_rn. --- analytics_mcp/coordinator.py | 2 ++ analytics_mcp/tools/admin/info.py | 32 ++++++++++++++++++++++ analytics_mcp/tools/utils.py | 25 ++++++++++++++++++ tests/info_test.py | 44 +++++++++++++++++++++++++++++++ tests/utils_test.py | 42 +++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+) diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index d7b0680..9a5c3eb 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -35,6 +35,7 @@ get_property_details, list_data_streams, list_key_events, + list_properties, list_property_annotations, ) from analytics_mcp.tools.reporting.core import ( @@ -80,6 +81,7 @@ FunctionTool(get_property_details), FunctionTool(list_data_streams), FunctionTool(list_key_events), + FunctionTool(list_properties), FunctionTool(list_property_annotations), FunctionTool(get_custom_dimensions_and_metrics), FunctionTool(get_metadata), diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index ba698e2..bebf0d9 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -18,6 +18,7 @@ from typing import Any, Dict, List from analytics_mcp.tools.utils import ( + construct_account_rn, construct_property_rn, proto_to_dict, ) @@ -78,6 +79,37 @@ def _sync_call(): return proto_to_dict(response) +async def list_properties( + account_id: int | str, show_deleted: bool = False +) -> List[Dict[str, Any]]: + """Returns the properties under a Google Analytics account. + + Returns full property objects, including display name, industry + category, time zone, currency code, service level, and create time. + Use this for deeper property discovery than `get_account_summaries`, + which only returns summary information. + + Args: + account_id: The Google Analytics account ID. Accepted formats are: + - A number + - A string consisting of 'accounts/' followed by a number + show_deleted: Whether to include soft-deleted (i.e. "trashed") + properties in the results. Defaults to False. + """ + request = admin_v1beta.ListPropertiesRequest( + filter=f"parent:{construct_account_rn(account_id)}", + show_deleted=show_deleted, + ) + + def _sync_call(): + properties_pager = create_admin_api_client().list_properties( + request=request + ) + return [proto_to_dict(prop) for prop in properties_pager] + + return await asyncio.to_thread(_sync_call) + + async def list_key_events(property_id: int | str) -> List[Dict[str, Any]]: """Returns the key events configured for a property. diff --git a/analytics_mcp/tools/utils.py b/analytics_mcp/tools/utils.py index 66852f3..e439ddb 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -44,6 +44,31 @@ def construct_property_rn(property_value: int | str) -> str: return f"properties/{property_num}" +def construct_account_rn(account_value: int | str) -> str: + """Returns an account resource name in the format required by APIs.""" + account_num = None + if isinstance(account_value, int): + account_num = account_value + elif isinstance(account_value, str): + account_value = account_value.strip() + if account_value.isdigit(): + account_num = int(account_value) + elif account_value.startswith("accounts/"): + numeric_part = account_value.split("/")[-1] + if numeric_part.isdigit(): + account_num = int(numeric_part) + if account_num is None: + raise ValueError( + ( + f"Invalid account ID: {account_value}. " + "A valid account value is either a number or a string starting " + "with 'accounts/' and followed by a number." + ) + ) + + return f"accounts/{account_num}" + + def proto_to_dict(obj: proto.Message) -> Dict[str, Any]: """Converts a proto message to a dictionary.""" return type(obj).to_dict( diff --git a/tests/info_test.py b/tests/info_test.py index 54d36cc..a822d2d 100644 --- a/tests/info_test.py +++ b/tests/info_test.py @@ -23,6 +23,7 @@ from analytics_mcp.tools.admin.info import ( list_data_streams, list_key_events, + list_properties, ) @@ -105,5 +106,48 @@ def test_invalid_property_id_raises(self): asyncio.run(list_data_streams("bogus")) +class TestListProperties(unittest.TestCase): + """Test cases for list_properties.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_properties(self, mock_create_client): + """Tests that properties are listed with an account filter.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + prop = admin_v1beta.Property( + name="properties/12345", + display_name="My property", + time_zone="America/New_York", + currency_code="USD", + ) + mock_client.list_properties.return_value = [prop] + + result = asyncio.run(list_properties(98765)) + + request = mock_client.list_properties.call_args.kwargs["request"] + self.assertEqual(request.filter, "parent:accounts/98765") + self.assertFalse(request.show_deleted) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["display_name"], "My property") + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_show_deleted_passthrough(self, mock_create_client): + """Tests that show_deleted is passed through to the request.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.list_properties.return_value = [] + + asyncio.run(list_properties("accounts/55", show_deleted=True)) + + request = mock_client.list_properties.call_args.kwargs["request"] + self.assertEqual(request.filter, "parent:accounts/55") + self.assertTrue(request.show_deleted) + + def test_invalid_account_id_raises(self): + """Tests that an invalid account ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_properties("properties/123")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/utils_test.py b/tests/utils_test.py index a521836..4d6d677 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -68,3 +68,45 @@ def test_construct_property_rn_invalid_input(self): msg="Resource name with more than 2 components should fail", ): utils.construct_property_rn("properties/123/abc") + + def test_construct_account_rn(self): + """Tests construct_account_rn using valid input.""" + self.assertEqual( + utils.construct_account_rn(12345), + "accounts/12345", + "Numeric account ID should be considered valid", + ) + self.assertEqual( + utils.construct_account_rn("12345"), + "accounts/12345", + "Numeric account ID as string should be considered valid", + ) + self.assertEqual( + utils.construct_account_rn(" 12345 "), + "accounts/12345", + "Whitespace around account ID should be considered valid", + ) + self.assertEqual( + utils.construct_account_rn("accounts/12345"), + "accounts/12345", + "Full resource name should be considered valid", + ) + + def test_construct_account_rn_invalid_input(self): + """Tests that construct_account_rn raises a ValueError for invalid input.""" + with self.assertRaises(ValueError, msg="None should fail"): + utils.construct_account_rn(None) + with self.assertRaises(ValueError, msg="Empty string should fail"): + utils.construct_account_rn("") + with self.assertRaises( + ValueError, msg="Non-numeric string should fail" + ): + utils.construct_account_rn("abc") + with self.assertRaises( + ValueError, msg="Resource name without ID should fail" + ): + utils.construct_account_rn("accounts/") + with self.assertRaises( + ValueError, msg="Property resource name should fail" + ): + utils.construct_account_rn("properties/123") From 3c0d6b055872558902d3a3566af946c14e17ffe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 11 Jun 2026 12:17:42 -0400 Subject: [PATCH 5/9] feat: add list_custom_dimensions and list_custom_metrics tools Lists custom dimensions and metrics via the Admin API, which returns richer detail than the Data API metadata endpoint: scope, parameter name, description, measurement unit, and ads personalization / restricted metric flags. Read-only; fits the existing analytics.readonly scope. --- analytics_mcp/coordinator.py | 4 ++ analytics_mcp/tools/admin/info.py | 56 ++++++++++++++++++++++++++ tests/info_test.py | 66 +++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index 9a5c3eb..0135721 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -33,6 +33,8 @@ get_account_summaries, list_google_ads_links, get_property_details, + list_custom_dimensions, + list_custom_metrics, list_data_streams, list_key_events, list_properties, @@ -79,6 +81,8 @@ FunctionTool(get_account_summaries), FunctionTool(list_google_ads_links), FunctionTool(get_property_details), + FunctionTool(list_custom_dimensions), + FunctionTool(list_custom_metrics), FunctionTool(list_data_streams), FunctionTool(list_key_events), FunctionTool(list_properties), diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index bebf0d9..51141a9 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -162,6 +162,62 @@ def _sync_call(): return await asyncio.to_thread(_sync_call) +async def list_custom_dimensions( + property_id: int | str, +) -> List[Dict[str, Any]]: + """Returns the custom dimensions defined on a property (Admin API). + + Includes details that the Data API metadata endpoint doesn't return: + the dimension's scope (EVENT or USER), parameter name, description, + and whether it's excluded from ads personalization. Use the + `get_custom_dimensions_and_metrics` tool instead if you only need + the API names for building reports. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1beta.ListCustomDimensionsRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + dimensions_pager = create_admin_api_client().list_custom_dimensions( + request=request + ) + return [proto_to_dict(dimension) for dimension in dimensions_pager] + + return await asyncio.to_thread(_sync_call) + + +async def list_custom_metrics(property_id: int | str) -> List[Dict[str, Any]]: + """Returns the custom metrics defined on a property (Admin API). + + Includes details that the Data API metadata endpoint doesn't return: + the metric's scope, parameter name, description, measurement unit, + and restricted metric type. Use the + `get_custom_dimensions_and_metrics` tool instead if you only need + the API names for building reports. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1beta.ListCustomMetricsRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + metrics_pager = create_admin_api_client().list_custom_metrics( + request=request + ) + return [proto_to_dict(metric) for metric in metrics_pager] + + return await asyncio.to_thread(_sync_call) + + async def list_property_annotations( property_id: int | str, ) -> List[Dict[str, Any]]: diff --git a/tests/info_test.py b/tests/info_test.py index a822d2d..de0c395 100644 --- a/tests/info_test.py +++ b/tests/info_test.py @@ -21,6 +21,8 @@ from google.analytics import admin_v1beta from analytics_mcp.tools.admin.info import ( + list_custom_dimensions, + list_custom_metrics, list_data_streams, list_key_events, list_properties, @@ -149,5 +151,69 @@ def test_invalid_account_id_raises(self): asyncio.run(list_properties("properties/123")) +class TestListCustomDimensions(unittest.TestCase): + """Test cases for list_custom_dimensions.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_custom_dimensions(self, mock_create_client): + """Tests that custom dimensions include Admin API detail.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + dimension = admin_v1beta.CustomDimension( + name="properties/12345/customDimensions/1", + parameter_name="plan_type", + display_name="Plan type", + description="The subscription plan type.", + scope=admin_v1beta.CustomDimension.DimensionScope.EVENT, + ) + mock_client.list_custom_dimensions.return_value = [dimension] + + result = asyncio.run(list_custom_dimensions(12345)) + + request = mock_client.list_custom_dimensions.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["parameter_name"], "plan_type") + self.assertEqual(result[0]["scope"], "EVENT") + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_custom_dimensions("bogus")) + + +class TestListCustomMetrics(unittest.TestCase): + """Test cases for list_custom_metrics.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_custom_metrics(self, mock_create_client): + """Tests that custom metrics include Admin API detail.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + metric = admin_v1beta.CustomMetric( + name="properties/12345/customMetrics/1", + parameter_name="loyalty_points", + display_name="Loyalty points", + measurement_unit=( + admin_v1beta.CustomMetric.MeasurementUnit.STANDARD + ), + scope=admin_v1beta.CustomMetric.MetricScope.EVENT, + ) + mock_client.list_custom_metrics.return_value = [metric] + + result = asyncio.run(list_custom_metrics(12345)) + + request = mock_client.list_custom_metrics.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["parameter_name"], "loyalty_points") + self.assertEqual(result[0]["measurement_unit"], "STANDARD") + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_custom_metrics("bogus")) + + if __name__ == "__main__": unittest.main() From e89329d3216ddc54122b1066c5f817269bbaa57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 11 Jun 2026 12:20:05 -0400 Subject: [PATCH 6/9] feat: add run_access_report tool Runs a data access report via the Admin API runAccessReport method, showing who accessed Analytics data, when, and how. Supports both property-level and account-level entities. Read-only; the method accepts the analytics.readonly scope. --- analytics_mcp/coordinator.py | 9 ++ analytics_mcp/tools/admin/access.py | 99 ++++++++++++++++++++ tests/access_test.py | 137 ++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 analytics_mcp/tools/admin/access.py create mode 100644 tests/access_test.py diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index 0135721..8cd5f6e 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -29,6 +29,7 @@ from google.adk.tools.function_tool import FunctionTool from google.adk.tools.mcp_tool.conversion_utils import adk_to_mcp_tool_type +from analytics_mcp.tools.admin.access import run_access_report from analytics_mcp.tools.admin.info import ( get_account_summaries, list_google_ads_links, @@ -87,6 +88,7 @@ FunctionTool(list_key_events), FunctionTool(list_properties), FunctionTool(list_property_annotations), + FunctionTool(run_access_report), FunctionTool(get_custom_dimensions_and_metrics), FunctionTool(get_metadata), run_report_with_description, @@ -154,6 +156,13 @@ def sanitize_mcp_schema_properties(node: dict) -> None: ] elif tool.name == "run_realtime_report": tool.inputSchema["required"] = ["property_id", "dimensions", "metrics"] + elif tool.name == "run_access_report": + tool.inputSchema["required"] = [ + "entity", + "date_ranges", + "dimensions", + "metrics", + ] elif tool.name == "run_conversions_report": tool.inputSchema["required"] = [ "property_id", diff --git a/analytics_mcp/tools/admin/access.py b/analytics_mcp/tools/admin/access.py new file mode 100644 index 0000000..9a2dd86 --- /dev/null +++ b/analytics_mcp/tools/admin/access.py @@ -0,0 +1,99 @@ +# 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. + +"""Tools for running data access reports using the Admin API.""" + +import asyncio +from typing import Any, Dict, List + +from analytics_mcp.tools.utils import ( + construct_account_rn, + construct_property_rn, + proto_to_dict, +) +from analytics_mcp.tools.client import create_admin_api_client +from google.analytics import admin_v1beta + + +def _construct_access_report_entity(entity: int | str) -> str: + """Returns the entity resource name for an access report request. + + Accepts a property (number or 'properties/N') or an account + ('accounts/N'). + """ + if isinstance(entity, str) and entity.strip().startswith("accounts/"): + return construct_account_rn(entity) + return construct_property_rn(entity) + + +async def run_access_report( + entity: int | str, + date_ranges: List[Dict[str, str]], + dimensions: List[str], + metrics: List[str], + limit: int = None, + offset: int = None, + return_entity_quota: bool = False, +) -> Dict[str, Any]: + """Runs a data access report showing who accessed Analytics data. + + Each row describes data access events, e.g. which user accessed + which property, when, from where, and how many data requests were + made. Useful for compliance and security auditing. + + Access reports use their own dimension and metric names, documented + at + https://developers.google.com/analytics/devguides/config/admin/v1/access-api-schema. + Common dimensions include `userEmail`, `epochTimeMicros`, + `accessMechanism`, and `reportType`. Common metrics include + `accessCount`. + + Args: + entity: The entity to report on. Accepted formats are: + - A number (treated as a property ID) + - A string consisting of 'properties/' followed by a number + - A string consisting of 'accounts/' followed by a number, + which reports on all properties under the account + date_ranges: A list of date range dicts, each with `start_date` + and `end_date` keys. Dates are in `YYYY-MM-DD` format, or the + relative forms `today`, `yesterday`, and `NdaysAgo`. + dimensions: A list of access report dimension names. + metrics: A list of access report metric names. + limit: The maximum number of rows to return (max 100,000). + offset: The row count of the start row (0-indexed). + return_entity_quota: Whether to include the current state of + this property's access report quota in the response. + """ + request = admin_v1beta.RunAccessReportRequest( + entity=_construct_access_report_entity(entity), + date_ranges=[admin_v1beta.AccessDateRange(dr) for dr in date_ranges], + dimensions=[ + admin_v1beta.AccessDimension(dimension_name=d) for d in dimensions + ], + metrics=[admin_v1beta.AccessMetric(metric_name=m) for m in metrics], + return_entity_quota=return_entity_quota, + ) + + if limit: + request.limit = limit + + if offset: + request.offset = offset + + def _sync_call(): + return create_admin_api_client().run_access_report(request=request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/tests/access_test.py b/tests/access_test.py new file mode 100644 index 0000000..08a933b --- /dev/null +++ b/tests/access_test.py @@ -0,0 +1,137 @@ +# 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 the run_access_report tool.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import admin_v1beta + +from analytics_mcp.tools.admin.access import ( + _construct_access_report_entity, + run_access_report, +) + + +class TestConstructAccessReportEntity(unittest.TestCase): + """Test cases for _construct_access_report_entity.""" + + def test_property_forms(self): + """Tests property ID forms resolve to a property entity.""" + self.assertEqual( + _construct_access_report_entity(12345), "properties/12345" + ) + self.assertEqual( + _construct_access_report_entity("properties/12345"), + "properties/12345", + ) + + def test_account_form(self): + """Tests an 'accounts/' string resolves to an account entity.""" + self.assertEqual( + _construct_access_report_entity("accounts/678"), "accounts/678" + ) + + def test_invalid_entity_raises(self): + """Tests that invalid entities raise a ValueError.""" + with self.assertRaises(ValueError): + _construct_access_report_entity("bogus") + with self.assertRaises(ValueError): + _construct_access_report_entity("accounts/abc") + + +class TestRunAccessReport(unittest.TestCase): + """Test cases for run_access_report.""" + + @patch("analytics_mcp.tools.admin.access.create_admin_api_client") + def test_builds_request(self, mock_create_client): + """Tests that the request proto is built correctly.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.run_access_report.return_value = ( + admin_v1beta.RunAccessReportResponse(row_count=0) + ) + + asyncio.run( + run_access_report( + 12345, + date_ranges=[{"start_date": "7daysAgo", "end_date": "today"}], + dimensions=["userEmail", "accessMechanism"], + metrics=["accessCount"], + limit=50, + ) + ) + + request = mock_client.run_access_report.call_args.kwargs["request"] + self.assertEqual(request.entity, "properties/12345") + self.assertEqual(len(request.date_ranges), 1) + self.assertEqual(request.date_ranges[0].start_date, "7daysAgo") + self.assertEqual( + [d.dimension_name for d in request.dimensions], + ["userEmail", "accessMechanism"], + ) + self.assertEqual( + [m.metric_name for m in request.metrics], ["accessCount"] + ) + self.assertEqual(request.limit, 50) + self.assertFalse(request.return_entity_quota) + + @patch("analytics_mcp.tools.admin.access.create_admin_api_client") + def test_account_level_report(self, mock_create_client): + """Tests that account-level entities are passed through.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.run_access_report.return_value = ( + admin_v1beta.RunAccessReportResponse() + ) + + asyncio.run( + run_access_report( + "accounts/99", + date_ranges=[ + {"start_date": "2025-01-01", "end_date": "2025-01-31"} + ], + dimensions=["userEmail"], + metrics=["accessCount"], + ) + ) + + request = mock_client.run_access_report.call_args.kwargs["request"] + self.assertEqual(request.entity, "accounts/99") + + @patch("analytics_mcp.tools.admin.access.create_admin_api_client") + def test_converts_response(self, mock_create_client): + """Tests that the proto response is converted to a dict.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.run_access_report.return_value = ( + admin_v1beta.RunAccessReportResponse(row_count=2) + ) + + result = asyncio.run( + run_access_report( + 12345, + date_ranges=[{"start_date": "yesterday", "end_date": "today"}], + dimensions=["userEmail"], + metrics=["accessCount"], + ) + ) + + self.assertEqual(result["row_count"], 2) + + +if __name__ == "__main__": + unittest.main() From 37eecfd848fce64464f0d34093b081d021673bd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 11 Jun 2026 12:20:49 -0400 Subject: [PATCH 7/9] feat: add get_data_retention_settings tool Retrieves a property's data retention settings via the Admin API, showing how long event-level and user-level data is kept. Helps answer compliance questions and explains why older data may be missing from reports. Read-only; fits the existing analytics.readonly scope. --- analytics_mcp/coordinator.py | 2 ++ analytics_mcp/tools/admin/info.py | 28 ++++++++++++++++++++++++++ tests/info_test.py | 33 +++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index 8cd5f6e..e05c545 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -32,6 +32,7 @@ from analytics_mcp.tools.admin.access import run_access_report from analytics_mcp.tools.admin.info import ( get_account_summaries, + get_data_retention_settings, list_google_ads_links, get_property_details, list_custom_dimensions, @@ -82,6 +83,7 @@ FunctionTool(get_account_summaries), FunctionTool(list_google_ads_links), FunctionTool(get_property_details), + FunctionTool(get_data_retention_settings), FunctionTool(list_custom_dimensions), FunctionTool(list_custom_metrics), FunctionTool(list_data_streams), diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index 51141a9..0b9cbdc 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -162,6 +162,34 @@ def _sync_call(): return await asyncio.to_thread(_sync_call) +async def get_data_retention_settings( + property_id: int | str, +) -> Dict[str, Any]: + """Returns the data retention settings for a property. + + Shows how long event-level and user-level data is kept before being + automatically deleted. Useful for compliance questions and for + knowing how far back reports can reliably look: if retention is set + to TWO_MONTHS, event-scoped data older than that is unavailable in + explorations and some reports. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1beta.GetDataRetentionSettingsRequest( + name=f"{construct_property_rn(property_id)}/dataRetentionSettings" + ) + + def _sync_call(): + client = create_admin_api_client() + return client.get_data_retention_settings(request=request) + + response = await asyncio.to_thread(_sync_call) + return proto_to_dict(response) + + async def list_custom_dimensions( property_id: int | str, ) -> List[Dict[str, Any]]: diff --git a/tests/info_test.py b/tests/info_test.py index de0c395..12a3023 100644 --- a/tests/info_test.py +++ b/tests/info_test.py @@ -21,6 +21,7 @@ from google.analytics import admin_v1beta from analytics_mcp.tools.admin.info import ( + get_data_retention_settings, list_custom_dimensions, list_custom_metrics, list_data_streams, @@ -215,5 +216,37 @@ def test_invalid_property_id_raises(self): asyncio.run(list_custom_metrics("bogus")) +class TestGetDataRetentionSettings(unittest.TestCase): + """Test cases for get_data_retention_settings.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_settings(self, mock_create_client): + """Tests that retention settings are fetched and converted.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + settings = admin_v1beta.DataRetentionSettings( + name="properties/12345/dataRetentionSettings", + event_data_retention=( + admin_v1beta.DataRetentionSettings.RetentionDuration.FOURTEEN_MONTHS + ), + reset_user_data_on_new_activity=True, + ) + mock_client.get_data_retention_settings.return_value = settings + + result = asyncio.run(get_data_retention_settings(12345)) + + request = mock_client.get_data_retention_settings.call_args.kwargs[ + "request" + ] + self.assertEqual(request.name, "properties/12345/dataRetentionSettings") + self.assertEqual(result["event_data_retention"], "FOURTEEN_MONTHS") + self.assertTrue(result["reset_user_data_on_new_activity"]) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(get_data_retention_settings("bogus")) + + if __name__ == "__main__": unittest.main() From 97cbad91e6a3620f052c1730466dab2b73853d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 11 Jun 2026 12:21:48 -0400 Subject: [PATCH 8/9] feat: add list_audiences tool Lists the audiences defined on a GA4 property via the Admin API alpha channel, following the same pattern as the existing list_property_annotations tool. Read-only; fits the existing analytics.readonly scope. --- analytics_mcp/coordinator.py | 2 ++ analytics_mcp/tools/admin/info.py | 29 +++++++++++++++++++++++++++ tests/info_test.py | 33 ++++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index e05c545..ddda93c 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -35,6 +35,7 @@ get_data_retention_settings, list_google_ads_links, get_property_details, + list_audiences, list_custom_dimensions, list_custom_metrics, list_data_streams, @@ -84,6 +85,7 @@ FunctionTool(list_google_ads_links), FunctionTool(get_property_details), FunctionTool(get_data_retention_settings), + FunctionTool(list_audiences), FunctionTool(list_custom_dimensions), FunctionTool(list_custom_metrics), FunctionTool(list_data_streams), diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index 0b9cbdc..3a05dcc 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -162,6 +162,35 @@ def _sync_call(): return await asyncio.to_thread(_sync_call) +async def list_audiences(property_id: int | str) -> List[Dict[str, Any]]: + """Returns the audiences defined on a property. + + Audiences are segments of users, e.g. "Purchasers" or "Users who + visited the pricing page". Knowing what audiences exist helps when + interpreting audience-scoped dimensions in reports or suggesting + targeting strategies. + + Note: this uses the alpha channel of the Admin API, which may + change without notice. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1alpha.ListAudiencesRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + audiences_pager = create_admin_alpha_api_client().list_audiences( + request=request + ) + return [proto_to_dict(audience) for audience in audiences_pager] + + return await asyncio.to_thread(_sync_call) + + async def get_data_retention_settings( property_id: int | str, ) -> Dict[str, Any]: diff --git a/tests/info_test.py b/tests/info_test.py index 12a3023..2c03ad1 100644 --- a/tests/info_test.py +++ b/tests/info_test.py @@ -18,10 +18,11 @@ import unittest from unittest.mock import MagicMock, patch -from google.analytics import admin_v1beta +from google.analytics import admin_v1alpha, admin_v1beta from analytics_mcp.tools.admin.info import ( get_data_retention_settings, + list_audiences, list_custom_dimensions, list_custom_metrics, list_data_streams, @@ -248,5 +249,35 @@ def test_invalid_property_id_raises(self): asyncio.run(get_data_retention_settings("bogus")) +class TestListAudiences(unittest.TestCase): + """Test cases for list_audiences.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_alpha_api_client") + def test_returns_audiences(self, mock_create_client): + """Tests that audiences are listed via the alpha client.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + audience = admin_v1alpha.Audience( + name="properties/12345/audiences/777", + display_name="Purchasers", + description="Users who purchased.", + membership_duration_days=30, + ) + mock_client.list_audiences.return_value = [audience] + + result = asyncio.run(list_audiences(12345)) + + request = mock_client.list_audiences.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["display_name"], "Purchasers") + self.assertEqual(result[0]["membership_duration_days"], 30) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_audiences("bogus")) + + if __name__ == "__main__": unittest.main() From 4b849f5599b655886f6bcf1d13020f5f0f8a19b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 11 Jun 2026 12:22:29 -0400 Subject: [PATCH 9/9] feat: add get_property_quotas tool Retrieves the current Data API quota snapshot for a property via the alpha getPropertyQuotasSnapshot method, so callers can check token consumption before running expensive batches and back off intelligently. Read-only; fits the existing analytics.readonly scope. --- analytics_mcp/coordinator.py | 2 + analytics_mcp/tools/reporting/quotas.py | 54 ++++++++++++++++++++ tests/quotas_test.py | 67 +++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 analytics_mcp/tools/reporting/quotas.py create mode 100644 tests/quotas_test.py diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index ddda93c..65612d6 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -55,6 +55,7 @@ get_custom_dimensions_and_metrics, get_metadata, ) +from analytics_mcp.tools.reporting.quotas import get_property_quotas from analytics_mcp.tools.reporting.funnel import ( run_funnel_report, _run_funnel_report_description, @@ -95,6 +96,7 @@ FunctionTool(run_access_report), FunctionTool(get_custom_dimensions_and_metrics), FunctionTool(get_metadata), + FunctionTool(get_property_quotas), run_report_with_description, run_realtime_report_with_description, run_funnel_report_with_description, diff --git a/analytics_mcp/tools/reporting/quotas.py b/analytics_mcp/tools/reporting/quotas.py new file mode 100644 index 0000000..b6866cb --- /dev/null +++ b/analytics_mcp/tools/reporting/quotas.py @@ -0,0 +1,54 @@ +# 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. + +"""Tools for inspecting Data API quota usage.""" + +import asyncio +from typing import Any, Dict + +from analytics_mcp.tools.utils import ( + construct_property_rn, + proto_to_dict, +) +from analytics_mcp.tools.client import create_data_api_alpha_client +from google.analytics import data_v1alpha + + +async def get_property_quotas(property_id: int | str) -> Dict[str, Any]: + """Returns the current Data API quota usage for a property. + + Shows consumed and remaining quota for core tokens (per day and per + hour), realtime tokens, funnel tokens, concurrent requests, and + server errors. Check this before running expensive report batches + so you can back off instead of hitting quota errors. + + Note: this uses the alpha channel of the Data API, which may change + without notice. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = data_v1alpha.GetPropertyQuotasSnapshotRequest( + name=f"{construct_property_rn(property_id)}/propertyQuotasSnapshot" + ) + + def _sync_call(): + client = create_data_api_alpha_client() + return client.get_property_quotas_snapshot(request=request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/tests/quotas_test.py b/tests/quotas_test.py new file mode 100644 index 0000000..4bdf347 --- /dev/null +++ b/tests/quotas_test.py @@ -0,0 +1,67 @@ +# 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 the get_property_quotas tool.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1alpha + +from analytics_mcp.tools.reporting.quotas import get_property_quotas + + +class TestGetPropertyQuotas(unittest.TestCase): + """Test cases for get_property_quotas.""" + + @patch("analytics_mcp.tools.reporting.quotas.create_data_api_alpha_client") + def test_returns_quota_snapshot(self, mock_create_client): + """Tests that the quota snapshot is fetched and converted.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + snapshot = data_v1alpha.PropertyQuotasSnapshot( + name="properties/12345/propertyQuotasSnapshot", + core_property_quota=data_v1alpha.PropertyQuota( + tokens_per_day=data_v1alpha.QuotaStatus( + consumed=100, remaining=199900 + ), + ), + ) + mock_client.get_property_quotas_snapshot.return_value = snapshot + + result = asyncio.run(get_property_quotas(12345)) + + request = mock_client.get_property_quotas_snapshot.call_args.kwargs[ + "request" + ] + self.assertEqual( + request.name, "properties/12345/propertyQuotasSnapshot" + ) + self.assertEqual( + result["core_property_quota"]["tokens_per_day"]["consumed"], 100 + ) + self.assertEqual( + result["core_property_quota"]["tokens_per_day"]["remaining"], + 199900, + ) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(get_property_quotas("bogus")) + + +if __name__ == "__main__": + unittest.main()