From 42648421cd4365fd6b5b1525126ecf7ca32725ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Simmons?= Date: Thu, 11 Jun 2026 12:17:39 -0400 Subject: [PATCH 1/6] chore: add CodeQL workflow (git-standards public_active tier) --- .github/workflows/codeql.yml | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..fdc7de54 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +name: "CodeQL" + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 6 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + language: [actions] + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + category: "/language:${{ matrix.language }}" From d1ad7c5812827a83969b92c8d78dbb2abb1ef88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 11 Jun 2026 12:31:12 -0400 Subject: [PATCH 2/6] feat: add audience export and Measurement Protocol tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audience exports (Data API v1beta, analytics.readonly-compatible): create_audience_export starts the async export job and returns the operation metadata for polling; get/list/query retrieve state and user rows. Measurement Protocol (plain HTTP, write-capable): validate_event hits only the debug endpoint and never records data. send_event is guarded for AI callers — the API secret comes solely from the ANALYTICS_MCP_MP_API_SECRET env var, dry-run validation is the default, recording requires an explicit confirm=True, and even confirmed sends abort if validation fails. --- analytics_mcp/coordinator.py | 28 ++ analytics_mcp/tools/measurement.py | 290 ++++++++++++++++++ .../tools/reporting/audience_exports.py | 212 +++++++++++++ tests/audience_exports_test.py | 192 ++++++++++++ tests/measurement_test.py | 181 +++++++++++ 5 files changed, 903 insertions(+) create mode 100644 analytics_mcp/tools/measurement.py create mode 100644 analytics_mcp/tools/reporting/audience_exports.py create mode 100644 tests/audience_exports_test.py create mode 100644 tests/measurement_test.py diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index b53516d2..2c94adcf 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -54,6 +54,16 @@ run_conversions_report, _run_conversions_report_description, ) +from analytics_mcp.tools.reporting.audience_exports import ( + create_audience_export, + get_audience_export, + list_audience_exports, + query_audience_export, +) +from analytics_mcp.tools.measurement import ( + validate_event, + send_event, +) run_report_with_description = FunctionTool(run_report) run_report_with_description.description = _run_report_description() @@ -81,6 +91,12 @@ run_realtime_report_with_description, run_funnel_report_with_description, run_conversions_report_with_description, + FunctionTool(create_audience_export), + FunctionTool(get_audience_export), + FunctionTool(list_audience_exports), + FunctionTool(query_audience_export), + FunctionTool(validate_event), + FunctionTool(send_event), ] tool_map = {t.name: t for t in tools} @@ -150,6 +166,18 @@ def sanitize_mcp_schema_properties(node: dict) -> None: "metrics", "conversion_spec", ] + elif tool.name == "create_audience_export": + tool.inputSchema["required"] = [ + "property_id", + "audience_id", + "dimensions", + ] + elif tool.name in ("validate_event", "send_event"): + tool.inputSchema["required"] = [ + "measurement_id", + "client_id", + "events", + ] @app.list_tools() diff --git a/analytics_mcp/tools/measurement.py b/analytics_mcp/tools/measurement.py new file mode 100644 index 00000000..5f8d7f7d --- /dev/null +++ b/analytics_mcp/tools/measurement.py @@ -0,0 +1,290 @@ +# 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 the Google Analytics Measurement Protocol. + +Unlike the other tools in this server, the Measurement Protocol is a +plain HTTP API (not a gRPC client library) and it WRITES data: events +sent to the collect endpoint are recorded in the property. To keep the +server safe by default: + +- The API secret is only ever read from the + ``ANALYTICS_MCP_MP_API_SECRET`` environment variable, never from tool + arguments, so a model cannot supply or exfiltrate secrets. +- ``send_event`` defaults to a dry run that only validates the payload. + Recording an event requires the explicit ``confirm=True`` argument, + and the payload must pass validation first. +""" + +import asyncio +import os +from typing import Any, Dict, List + +import httpx + +_MP_API_SECRET_ENV_VAR = "ANALYTICS_MCP_MP_API_SECRET" + +_COLLECT_URL = "https://www.google-analytics.com/mp/collect" +_DEBUG_COLLECT_URL = "https://www.google-analytics.com/debug/mp/collect" + +_REQUEST_TIMEOUT_SECONDS = 10.0 + +_MAX_EVENTS_PER_REQUEST = 25 + + +def _get_api_secret() -> str: + """Returns the Measurement Protocol API secret from the environment.""" + api_secret = os.environ.get(_MP_API_SECRET_ENV_VAR, "").strip() + if not api_secret: + raise ValueError( + "No Measurement Protocol API secret is configured. Set the " + f"{_MP_API_SECRET_ENV_VAR} environment variable to an API " + "secret created under the data stream's 'Measurement " + "Protocol API secrets' settings in Google Analytics." + ) + return api_secret + + +def _build_payload( + client_id: str, + events: List[Dict[str, Any]], + user_id: str = None, + timestamp_micros: int = None, + user_properties: Dict[str, Any] = None, + non_personalized_ads: bool = False, +) -> Dict[str, Any]: + """Builds and validates a Measurement Protocol request payload.""" + if not client_id or not str(client_id).strip(): + raise ValueError("client_id must be a non-empty string.") + if not isinstance(events, list) or not events: + raise ValueError("events must contain at least one event.") + if len(events) > _MAX_EVENTS_PER_REQUEST: + raise ValueError( + "events must contain at most " + f"{_MAX_EVENTS_PER_REQUEST} events. Got {len(events)}." + ) + for i, event in enumerate(events): + if not isinstance(event, dict): + raise ValueError(f"Event {i + 1} must be a dictionary.") + if not event.get("name"): + raise ValueError(f"Event {i + 1} is missing required key 'name'.") + + payload = { + "client_id": str(client_id), + "events": events, + } + + if user_id: + payload["user_id"] = user_id + + if timestamp_micros: + payload["timestamp_micros"] = timestamp_micros + + if user_properties: + payload["user_properties"] = user_properties + + if non_personalized_ads: + payload["non_personalized_ads"] = True + + return payload + + +def _post_payload( + url: str, measurement_id: str, payload: Dict[str, Any] +) -> httpx.Response: + """Posts a payload to a Measurement Protocol endpoint.""" + if not measurement_id or not str(measurement_id).strip(): + raise ValueError( + "measurement_id must be a non-empty string, e.g. 'G-XXXXXXX'. " + "Use the list_data_streams tool to find a web stream's " + "measurement ID." + ) + + response = httpx.post( + url, + params={ + "measurement_id": measurement_id, + "api_secret": _get_api_secret(), + }, + json=payload, + timeout=_REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + return response + + +def _validation_messages(response: httpx.Response) -> List[Dict[str, Any]]: + """Extracts validation messages from a debug endpoint response.""" + try: + body = response.json() + except ValueError: + return [] + return body.get("validationMessages", []) + + +async def validate_event( + measurement_id: str, + client_id: str, + events: List[Dict[str, Any]], + user_id: str = None, + timestamp_micros: int = None, + user_properties: Dict[str, Any] = None, + non_personalized_ads: bool = False, +) -> Dict[str, Any]: + """Validates Measurement Protocol events without recording them. + + Sends the events to the Measurement Protocol debug endpoint, which + checks the payload and returns validation messages. Nothing is + recorded in the property, so this is always safe to call. + + Requires the ANALYTICS_MCP_MP_API_SECRET environment variable to be + set to a Measurement Protocol API secret for the data stream. + + Args: + measurement_id: The web data stream's measurement ID, e.g. + 'G-XXXXXXX'. Use the `list_data_streams` tool to find it. + client_id: A unique identifier for the client/user instance, + e.g. the GA client ID from the _ga cookie, or any stable + UUID-like string for server-generated events. + events: A list of 1 to 25 event objects. Each object must + contain a `name` key (e.g. 'tutorial_complete') and may + contain a `params` dict, per + https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events. + user_id: An optional persistent user identifier. + timestamp_micros: Optional Unix epoch microseconds for the + events. Must be within the last 72 hours. + user_properties: Optional user properties dict, e.g. + `{"plan": {"value": "premium"}}`. + non_personalized_ads: Whether the events should be excluded + from ads personalization. + """ + payload = _build_payload( + client_id, + events, + user_id=user_id, + timestamp_micros=timestamp_micros, + user_properties=user_properties, + non_personalized_ads=non_personalized_ads, + ) + + def _sync_call(): + return _post_payload(_DEBUG_COLLECT_URL, measurement_id, payload) + + response = await asyncio.to_thread(_sync_call) + messages = _validation_messages(response) + + return { + "valid": not messages, + "validation_messages": messages, + } + + +async def send_event( + measurement_id: str, + client_id: str, + events: List[Dict[str, Any]], + user_id: str = None, + timestamp_micros: int = None, + user_properties: Dict[str, Any] = None, + non_personalized_ads: bool = False, + confirm: bool = False, +) -> Dict[str, Any]: + """Sends Measurement Protocol events to a Google Analytics property. + + WARNING: with `confirm=True` this WRITES events into the property's + data, which cannot be undone. By default (`confirm=False`) this + tool performs a dry run: the payload is validated against the + debug endpoint and nothing is recorded. Only pass `confirm=True` + after the user has explicitly approved sending the events. + + Even with `confirm=True`, the payload is validated first and the + send is aborted if validation fails. + + Requires the ANALYTICS_MCP_MP_API_SECRET environment variable to be + set to a Measurement Protocol API secret for the data stream. + + Args: + measurement_id: The web data stream's measurement ID, e.g. + 'G-XXXXXXX'. Use the `list_data_streams` tool to find it. + client_id: A unique identifier for the client/user instance, + e.g. the GA client ID from the _ga cookie, or any stable + UUID-like string for server-generated events. + events: A list of 1 to 25 event objects. Each object must + contain a `name` key (e.g. 'tutorial_complete') and may + contain a `params` dict, per + https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events. + user_id: An optional persistent user identifier. + timestamp_micros: Optional Unix epoch microseconds for the + events. Must be within the last 72 hours. + user_properties: Optional user properties dict, e.g. + `{"plan": {"value": "premium"}}`. + non_personalized_ads: Whether the events should be excluded + from ads personalization. + confirm: Must be True to actually record the events. When + False (the default), only validation is performed and a + dry-run result is returned. + """ + validation = await validate_event( + measurement_id, + client_id, + events, + user_id=user_id, + timestamp_micros=timestamp_micros, + user_properties=user_properties, + non_personalized_ads=non_personalized_ads, + ) + + if not confirm: + return { + "sent": False, + "dry_run": True, + "validation": validation, + "message": ( + "Dry run only — no events were recorded. To send these " + "events for real, call send_event again with " + "confirm=True after the user has approved it." + ), + } + + if not validation["valid"]: + return { + "sent": False, + "dry_run": False, + "validation": validation, + "message": ( + "Events were NOT sent because validation failed. Fix " + "the issues in validation_messages and try again." + ), + } + + payload = _build_payload( + client_id, + events, + user_id=user_id, + timestamp_micros=timestamp_micros, + user_properties=user_properties, + non_personalized_ads=non_personalized_ads, + ) + + def _sync_call(): + return _post_payload(_COLLECT_URL, measurement_id, payload) + + await asyncio.to_thread(_sync_call) + + return { + "sent": True, + "dry_run": False, + "events_sent": len(events), + "validation": validation, + } diff --git a/analytics_mcp/tools/reporting/audience_exports.py b/analytics_mcp/tools/reporting/audience_exports.py new file mode 100644 index 00000000..51927679 --- /dev/null +++ b/analytics_mcp/tools/reporting/audience_exports.py @@ -0,0 +1,212 @@ +# 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 creating and querying audience exports using the Data API.""" + +import asyncio +from typing import Any, Dict, List + +from analytics_mcp.tools.utils import ( + construct_property_rn, + proto_to_dict, +) +from analytics_mcp.tools.client import create_data_api_client +from google.analytics import data_v1beta + + +def _construct_audience_export_rn( + property_id: int | str, audience_export: int | str +) -> str: + """Returns an audience export resource name. + + Args: + property_id: The property the export belongs to. + audience_export: Either a numeric audience export ID or a full + resource name of the form + 'properties/{property}/audienceExports/{id}'. + """ + if isinstance(audience_export, str): + audience_export = audience_export.strip() + if audience_export.startswith("properties/"): + return audience_export + + return ( + f"{construct_property_rn(property_id)}/" + f"audienceExports/{audience_export}" + ) + + +async def create_audience_export( + property_id: int | str, + audience_id: int | str, + dimensions: List[str], +) -> Dict[str, Any]: + """Starts an asynchronous audience export job. + + An audience export is a snapshot of the users in an audience at the + time of creation. Creating the export starts a long-running job; + poll its state with `get_audience_export`, and retrieve the user + rows with `query_audience_export` once the state is ACTIVE. + + Note: although this creates an export object, the operation only + reads analytics data and is permitted by the analytics.readonly + scope. Exports are retained for about 72 hours, and creation + charges audience export quota tokens. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + audience_id: The audience to export. Accepted formats are: + - A number (the audience ID) + - A full resource name, e.g. 'properties/1234/audiences/5678' + Use the `list_audiences` tool to discover audience IDs. + dimensions: The dimensions to include for each user, e.g. + `["deviceId"]`. Valid names are listed at + https://developers.google.com/analytics/devguides/reporting/data/v1/audience-list-basics#dimensions. + """ + property_rn = construct_property_rn(property_id) + + if isinstance(audience_id, str) and audience_id.strip().startswith( + "properties/" + ): + audience_rn = audience_id.strip() + else: + audience_rn = f"{property_rn}/audiences/{audience_id}" + + request = data_v1beta.CreateAudienceExportRequest( + parent=property_rn, + audience_export=data_v1beta.AudienceExport( + audience=audience_rn, + dimensions=[ + data_v1beta.AudienceDimension(dimension_name=d) + for d in dimensions + ], + ), + ) + + def _sync_call(): + operation = create_data_api_client().create_audience_export( + request=request + ) + # The operation metadata is the AudienceExport being created, + # including its name and state. Don't block on completion; + # callers poll with get_audience_export. + return operation.metadata + + metadata = await asyncio.to_thread(_sync_call) + + return proto_to_dict(metadata) + + +async def get_audience_export( + property_id: int | str, audience_export: int | str +) -> Dict[str, Any]: + """Returns the configuration and state of an audience export. + + Use this to poll an export created with `create_audience_export`. + When `state` is ACTIVE, the export can be queried with + `query_audience_export`. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + audience_export: The audience export to look up. Accepted + formats are: + - A number (the audience export ID) + - A full resource name, e.g. + 'properties/1234/audienceExports/5678' + """ + request = data_v1beta.GetAudienceExportRequest( + name=_construct_audience_export_rn(property_id, audience_export) + ) + + def _sync_call(): + return create_data_api_client().get_audience_export(request=request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) + + +async def list_audience_exports( + property_id: int | str, +) -> List[Dict[str, Any]]: + """Returns all audience exports for a property. + + Exports are retained for about 72 hours after creation. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = data_v1beta.ListAudienceExportsRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + exports_pager = create_data_api_client().list_audience_exports( + request=request + ) + return [proto_to_dict(export) for export in exports_pager] + + return await asyncio.to_thread(_sync_call) + + +async def query_audience_export( + property_id: int | str, + audience_export: int | str, + offset: int = None, + limit: int = None, +) -> Dict[str, Any]: + """Retrieves the user rows from a completed audience export. + + The export must be in the ACTIVE state; check with + `get_audience_export` first. Each row contains the dimension values + requested when the export was created. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + audience_export: The audience export to query. Accepted formats + are: + - A number (the audience export ID) + - A full resource name, e.g. + 'properties/1234/audienceExports/5678' + offset: The row count of the start row (0-indexed). + limit: The maximum number of rows to return. + """ + request = data_v1beta.QueryAudienceExportRequest( + name=_construct_audience_export_rn(property_id, audience_export) + ) + + if offset: + request.offset = offset + + if limit: + request.limit = limit + + def _sync_call(): + return create_data_api_client().query_audience_export(request=request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/tests/audience_exports_test.py b/tests/audience_exports_test.py new file mode 100644 index 00000000..336bf3e6 --- /dev/null +++ b/tests/audience_exports_test.py @@ -0,0 +1,192 @@ +# 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 audience export tools.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1beta + +from analytics_mcp.tools.reporting.audience_exports import ( + _construct_audience_export_rn, + create_audience_export, + get_audience_export, + list_audience_exports, + query_audience_export, +) + +_CLIENT_PATH = ( + "analytics_mcp.tools.reporting.audience_exports.create_data_api_client" +) + + +class TestConstructAudienceExportRn(unittest.TestCase): + """Test cases for _construct_audience_export_rn.""" + + def test_numeric_id(self): + self.assertEqual( + _construct_audience_export_rn(12345, 678), + "properties/12345/audienceExports/678", + ) + + def test_full_resource_name_passthrough(self): + self.assertEqual( + _construct_audience_export_rn( + 12345, "properties/999/audienceExports/1" + ), + "properties/999/audienceExports/1", + ) + + def test_invalid_property_raises(self): + with self.assertRaises(ValueError): + _construct_audience_export_rn("bogus", 678) + + +class TestCreateAudienceExport(unittest.TestCase): + """Test cases for create_audience_export.""" + + @patch(_CLIENT_PATH) + def test_builds_request_and_returns_metadata(self, mock_create_client): + """Tests request construction and metadata passthrough.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_operation = MagicMock() + mock_operation.metadata = data_v1beta.AudienceExport( + name="properties/12345/audienceExports/42", + audience="properties/12345/audiences/777", + state=data_v1beta.AudienceExport.State.CREATING, + ) + mock_client.create_audience_export.return_value = mock_operation + + result = asyncio.run( + create_audience_export(12345, 777, dimensions=["deviceId"]) + ) + + request = mock_client.create_audience_export.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual( + request.audience_export.audience, + "properties/12345/audiences/777", + ) + self.assertEqual( + [d.dimension_name for d in request.audience_export.dimensions], + ["deviceId"], + ) + self.assertEqual(result["name"], "properties/12345/audienceExports/42") + self.assertEqual(result["state"], "CREATING") + + @patch(_CLIENT_PATH) + def test_accepts_full_audience_rn(self, mock_create_client): + """Tests that a full audience resource name is passed through.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_operation = MagicMock() + mock_operation.metadata = data_v1beta.AudienceExport() + mock_client.create_audience_export.return_value = mock_operation + + asyncio.run( + create_audience_export( + 12345, + "properties/12345/audiences/888", + dimensions=["deviceId"], + ) + ) + + request = mock_client.create_audience_export.call_args.kwargs["request"] + self.assertEqual( + request.audience_export.audience, + "properties/12345/audiences/888", + ) + + +class TestGetAudienceExport(unittest.TestCase): + """Test cases for get_audience_export.""" + + @patch(_CLIENT_PATH) + def test_returns_export_state(self, mock_create_client): + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.get_audience_export.return_value = ( + data_v1beta.AudienceExport( + name="properties/12345/audienceExports/42", + state=data_v1beta.AudienceExport.State.ACTIVE, + row_count=100, + ) + ) + + result = asyncio.run(get_audience_export(12345, 42)) + + request = mock_client.get_audience_export.call_args.kwargs["request"] + self.assertEqual(request.name, "properties/12345/audienceExports/42") + self.assertEqual(result["state"], "ACTIVE") + self.assertEqual(result["row_count"], 100) + + +class TestListAudienceExports(unittest.TestCase): + """Test cases for list_audience_exports.""" + + @patch(_CLIENT_PATH) + def test_lists_exports(self, mock_create_client): + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.list_audience_exports.return_value = [ + data_v1beta.AudienceExport( + name="properties/12345/audienceExports/1" + ) + ] + + result = asyncio.run(list_audience_exports(12345)) + + request = mock_client.list_audience_exports.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + + +class TestQueryAudienceExport(unittest.TestCase): + """Test cases for query_audience_export.""" + + @patch(_CLIENT_PATH) + def test_queries_rows(self, mock_create_client): + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.query_audience_export.return_value = ( + data_v1beta.QueryAudienceExportResponse( + audience_rows=[ + data_v1beta.AudienceRow( + dimension_values=[ + data_v1beta.AudienceDimensionValue( + value="device-abc" + ) + ] + ) + ], + row_count=1, + ) + ) + + result = asyncio.run(query_audience_export(12345, 42, limit=10)) + + request = mock_client.query_audience_export.call_args.kwargs["request"] + self.assertEqual(request.name, "properties/12345/audienceExports/42") + self.assertEqual(request.limit, 10) + self.assertEqual( + result["audience_rows"][0]["dimension_values"][0]["value"], + "device-abc", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/measurement_test.py b/tests/measurement_test.py new file mode 100644 index 00000000..897933a3 --- /dev/null +++ b/tests/measurement_test.py @@ -0,0 +1,181 @@ +# 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 Measurement Protocol tools.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from analytics_mcp.tools import measurement +from analytics_mcp.tools.measurement import send_event, validate_event + +_SECRET_ENV = {"ANALYTICS_MCP_MP_API_SECRET": "test-secret"} + +_EVENTS = [{"name": "tutorial_complete", "params": {"step": 5}}] + + +def _mock_response(validation_messages=None): + """Returns a mock httpx response.""" + response = MagicMock() + response.json.return_value = ( + {"validationMessages": validation_messages} + if validation_messages is not None + else {} + ) + response.raise_for_status.return_value = None + return response + + +class TestValidateEvent(unittest.TestCase): + """Test cases for validate_event.""" + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_posts_to_debug_endpoint(self, mock_post): + """Tests that validation hits only the debug endpoint.""" + mock_post.return_value = _mock_response(validation_messages=[]) + + result = asyncio.run(validate_event("G-TEST123", "client-1", _EVENTS)) + + self.assertTrue(result["valid"]) + self.assertEqual(result["validation_messages"], []) + self.assertEqual(mock_post.call_count, 1) + url = mock_post.call_args.args[0] + self.assertEqual(url, measurement._DEBUG_COLLECT_URL) + params = mock_post.call_args.kwargs["params"] + self.assertEqual(params["measurement_id"], "G-TEST123") + self.assertEqual(params["api_secret"], "test-secret") + payload = mock_post.call_args.kwargs["json"] + self.assertEqual(payload["client_id"], "client-1") + self.assertEqual(payload["events"], _EVENTS) + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_reports_validation_messages(self, mock_post): + """Tests that validation messages are surfaced.""" + messages = [{"description": "bad event name"}] + mock_post.return_value = _mock_response(validation_messages=messages) + + result = asyncio.run(validate_event("G-TEST123", "client-1", _EVENTS)) + + self.assertFalse(result["valid"]) + self.assertEqual(result["validation_messages"], messages) + + @patch.dict("os.environ", {"ANALYTICS_MCP_MP_API_SECRET": ""}) + def test_missing_secret_raises(self): + """Tests that a missing API secret raises a clear error.""" + with self.assertRaises(ValueError) as ctx: + asyncio.run(validate_event("G-TEST123", "client-1", _EVENTS)) + self.assertIn("ANALYTICS_MCP_MP_API_SECRET", str(ctx.exception)) + + @patch.dict("os.environ", _SECRET_ENV) + def test_too_many_events_raises(self): + """Tests that more than 25 events raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run( + validate_event("G-TEST123", "client-1", [{"name": "e"}] * 26) + ) + + @patch.dict("os.environ", _SECRET_ENV) + def test_event_without_name_raises(self): + """Tests that an event without a name raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run( + validate_event("G-TEST123", "client-1", [{"params": {}}]) + ) + + +class TestSendEvent(unittest.TestCase): + """Test cases for send_event.""" + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_dry_run_by_default(self, mock_post): + """Tests that without confirm=True nothing is recorded.""" + mock_post.return_value = _mock_response(validation_messages=[]) + + result = asyncio.run(send_event("G-TEST123", "client-1", _EVENTS)) + + self.assertFalse(result["sent"]) + self.assertTrue(result["dry_run"]) + self.assertTrue(result["validation"]["valid"]) + # Only the debug endpoint may be called on a dry run. + urls = [call.args[0] for call in mock_post.call_args_list] + self.assertEqual(urls, [measurement._DEBUG_COLLECT_URL]) + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_confirm_sends_after_validation(self, mock_post): + """Tests that confirm=True validates then sends.""" + mock_post.return_value = _mock_response(validation_messages=[]) + + result = asyncio.run( + send_event("G-TEST123", "client-1", _EVENTS, confirm=True) + ) + + self.assertTrue(result["sent"]) + self.assertFalse(result["dry_run"]) + self.assertEqual(result["events_sent"], 1) + urls = [call.args[0] for call in mock_post.call_args_list] + self.assertEqual( + urls, + [measurement._DEBUG_COLLECT_URL, measurement._COLLECT_URL], + ) + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_confirm_blocked_by_validation_failure(self, mock_post): + """Tests that invalid payloads are never sent, even confirmed.""" + mock_post.return_value = _mock_response( + validation_messages=[{"description": "bad"}] + ) + + result = asyncio.run( + send_event("G-TEST123", "client-1", _EVENTS, confirm=True) + ) + + self.assertFalse(result["sent"]) + urls = [call.args[0] for call in mock_post.call_args_list] + self.assertEqual(urls, [measurement._DEBUG_COLLECT_URL]) + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_optional_fields_in_payload(self, mock_post): + """Tests that optional payload fields are passed through.""" + mock_post.return_value = _mock_response(validation_messages=[]) + + asyncio.run( + send_event( + "G-TEST123", + "client-1", + _EVENTS, + user_id="user-9", + timestamp_micros=1700000000000000, + user_properties={"plan": {"value": "premium"}}, + non_personalized_ads=True, + ) + ) + + payload = mock_post.call_args.kwargs["json"] + self.assertEqual(payload["user_id"], "user-9") + self.assertEqual(payload["timestamp_micros"], 1700000000000000) + self.assertEqual( + payload["user_properties"], {"plan": {"value": "premium"}} + ) + self.assertTrue(payload["non_personalized_ads"]) + + +if __name__ == "__main__": + unittest.main() From 652b70269f73031144f147f395029b54891330f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Wed, 15 Jul 2026 03:34:53 -0400 Subject: [PATCH 3/6] chore: gitignore .stargazer-context.json --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 693b25ac..03f0f9bc 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,5 @@ system_tests/local_test_setup pylintrc pylintrc.test -.agents/skills \ No newline at end of file +.agents/skills +.stargazer-* \ No newline at end of file From 851ad10e36aa94e438e80f9f23e4c76bb3e73d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 16 Jul 2026 11:25:10 -0400 Subject: [PATCH 4/6] Fix error signalling + tidy param guards and error handling (FR review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from a stargazer review of the server: - coordinator: call_mcp_tool now returns CallToolResult(content=[...], isError=True) on both failure paths (tool raised, and tool-not-found) instead of a plain TextContent that MCP clients read as a *successful* response. This is the one genuinely client-observable fix — clients can now detect failures programmatically. [FR-179] - coordinator: drop the unused `from json import tool` import, which bound `tool` to the json.tool CLI module only for it to be shadowed by later `for tool in ...` loops. [FR-173] - client: `except:` -> `except Exception:` in the package-version fallback so Ctrl-C / SystemExit are no longer swallowed into "unknown". [FR-175] - reporting (core, realtime, audience_exports, conversions): replace truthy `if limit:` / `if offset:` / `if currency_code:` guards with `is not None`. NOTE: these RunReportRequest fields are proto3 scalars with no field presence, so offset=0 / limit=0 serialize identically to unset — this is a correctness/consistency cleanup and a guard against presence-aware fields, NOT a behavior change today (verified by comparing serialized requests). [FR-170, FR-171, FR-172, FR-181] Verified: black --check clean; existing suite (19 tests) green; ad-hoc harness confirms isError=True on both failure paths, the success path still returns list[Content], and offset/limit=0 requests are wire-identical to unset. --- analytics_mcp/coordinator.py | 19 ++++++++++++++----- analytics_mcp/tools/client.py | 2 +- .../tools/reporting/audience_exports.py | 4 ++-- analytics_mcp/tools/reporting/conversions.py | 6 +++--- analytics_mcp/tools/reporting/core.py | 6 +++--- analytics_mcp/tools/reporting/realtime.py | 4 ++-- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index 2c94adcf..60d3fd68 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -21,7 +21,6 @@ # MCP Server Imports import json import sys -from json import tool from mcp import types as mcp_types # Use alias to avoid conflict from mcp.server.lowlevel import Server @@ -186,7 +185,9 @@ async def list_tools() -> list[mcp_types.Tool]: @app.call_tool() -async def call_mcp_tool(name: str, arguments: dict) -> list[mcp_types.Content]: +async def call_mcp_tool( + name: str, arguments: dict +) -> list[mcp_types.Content] | mcp_types.CallToolResult: if name in tool_map: tool = tool_map[name] try: @@ -204,13 +205,21 @@ async def call_mcp_tool(name: str, arguments: dict) -> list[mcp_types.Content]: f"MCP Server: Error executing ADK tool '{name}': {e}", file=sys.stderr, ) - # Return an error message in MCP format + # Return an error message in MCP format. isError=True so MCP + # clients can detect the failure programmatically instead of + # treating the error text as a successful response. error_text = json.dumps( {"error": f"Failed to execute tool '{name}': {str(e)}"} ) - return [mcp_types.TextContent(type="text", text=error_text)] + return mcp_types.CallToolResult( + content=[mcp_types.TextContent(type="text", text=error_text)], + isError=True, + ) error_text = json.dumps( {"error": f"Tool '{name}' not implemented by this server."} ) - return [mcp_types.TextContent(type="text", text=error_text)] + return mcp_types.CallToolResult( + content=[mcp_types.TextContent(type="text", text=error_text)], + isError=True, + ) diff --git a/analytics_mcp/tools/client.py b/analytics_mcp/tools/client.py index 93d23258..44a156aa 100644 --- a/analytics_mcp/tools/client.py +++ b/analytics_mcp/tools/client.py @@ -37,7 +37,7 @@ def _get_package_version_with_fallback(): """ try: return metadata.version("analytics-mcp") - except: + except Exception: return "unknown" diff --git a/analytics_mcp/tools/reporting/audience_exports.py b/analytics_mcp/tools/reporting/audience_exports.py index 51927679..1a568960 100644 --- a/analytics_mcp/tools/reporting/audience_exports.py +++ b/analytics_mcp/tools/reporting/audience_exports.py @@ -198,10 +198,10 @@ async def query_audience_export( name=_construct_audience_export_rn(property_id, audience_export) ) - if offset: + if offset is not None: request.offset = offset - if limit: + if limit is not None: request.limit = limit def _sync_call(): diff --git a/analytics_mcp/tools/reporting/conversions.py b/analytics_mcp/tools/reporting/conversions.py index 9dbc7d3f..380c7cd6 100644 --- a/analytics_mcp/tools/reporting/conversions.py +++ b/analytics_mcp/tools/reporting/conversions.py @@ -175,11 +175,11 @@ async def run_conversions_report( data_v1alpha.OrderBy(order_by) for order_by in order_bys ] - if limit: + if limit is not None: request.limit = limit - if offset: + if offset is not None: request.offset = offset - if currency_code: + if currency_code is not None: request.currency_code = currency_code def _sync_call(): diff --git a/analytics_mcp/tools/reporting/core.py b/analytics_mcp/tools/reporting/core.py index e016d7e1..7dc21478 100644 --- a/analytics_mcp/tools/reporting/core.py +++ b/analytics_mcp/tools/reporting/core.py @@ -161,11 +161,11 @@ async def run_report( data_v1beta.OrderBy(order_by) for order_by in order_bys ] - if limit: + if limit is not None: request.limit = limit - if offset: + if offset is not None: request.offset = offset - if currency_code: + if currency_code is not None: request.currency_code = currency_code def _sync_call(): diff --git a/analytics_mcp/tools/reporting/realtime.py b/analytics_mcp/tools/reporting/realtime.py index ee9b1b05..45f41d4b 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -153,9 +153,9 @@ async def run_realtime_report( data_v1beta.OrderBy(order_by) for order_by in order_bys ] - if limit: + if limit is not None: request.limit = limit - if offset: + if offset is not None: request.offset = offset def _sync_call(): From 0fe9c499196d365e362ac435da1727445961f5ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Simmons?= Date: Wed, 19 Aug 2026 13:24:04 -0400 Subject: [PATCH 5/6] feat: add pivot report and compatibility tools (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt on top of current main rather than rebased — the original branch's coordinator.py hunk conflicted through an unterminated import statement, so a union merge produced nested open imports. Reapplied the branch's actual additions (imports, three FunctionTool wrappers, three registry entries, three inputSchema clauses) at the correct anchors in main's current file. pivot.py, compatibility.py and their tests are unchanged from the original branch. --- analytics_mcp/coordinator.py | 32 ++ .../tools/reporting/compatibility.py | 95 +++++ analytics_mcp/tools/reporting/pivot.py | 358 ++++++++++++++++++ tests/compatibility_test.py | 115 ++++++ tests/pivot_test.py | 214 +++++++++++ 5 files changed, 814 insertions(+) create mode 100644 analytics_mcp/tools/reporting/compatibility.py create mode 100644 analytics_mcp/tools/reporting/pivot.py create mode 100644 tests/compatibility_test.py create mode 100644 tests/pivot_test.py diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index 60d3fd68..543c7c69 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -63,6 +63,13 @@ validate_event, send_event, ) +from analytics_mcp.tools.reporting.compatibility import check_compatibility +from analytics_mcp.tools.reporting.pivot import ( + run_pivot_report, + _run_pivot_report_description, + batch_run_pivot_reports, + _batch_run_pivot_reports_description, +) run_report_with_description = FunctionTool(run_report) run_report_with_description.description = _run_report_description() @@ -80,7 +87,17 @@ ) # Instantiate the ADK tools +run_pivot_report_with_description = FunctionTool(run_pivot_report) +run_pivot_report_with_description.description = _run_pivot_report_description() +batch_run_pivot_reports_with_description = FunctionTool(batch_run_pivot_reports) +batch_run_pivot_reports_with_description.description = ( + _batch_run_pivot_reports_description() +) + tools = [ + FunctionTool(check_compatibility), + run_pivot_report_with_description, + batch_run_pivot_reports_with_description, FunctionTool(get_account_summaries), FunctionTool(list_google_ads_links), FunctionTool(get_property_details), @@ -155,6 +172,21 @@ def sanitize_mcp_schema_properties(node: dict) -> None: "dimensions", "metrics", ] + elif tool.name == "check_compatibility": + tool.inputSchema["required"] = ["property_id"] + elif tool.name == "run_pivot_report": + tool.inputSchema["required"] = [ + "property_id", + "date_ranges", + "dimensions", + "metrics", + "pivots", + ] + elif tool.name == "batch_run_pivot_reports": + tool.inputSchema["required"] = [ + "property_id", + "requests", + ] elif tool.name == "run_realtime_report": tool.inputSchema["required"] = ["property_id", "dimensions", "metrics"] elif tool.name == "run_conversions_report": diff --git a/analytics_mcp/tools/reporting/compatibility.py b/analytics_mcp/tools/reporting/compatibility.py new file mode 100644 index 00000000..0a2ab2a8 --- /dev/null +++ b/analytics_mcp/tools/reporting/compatibility.py @@ -0,0 +1,95 @@ +# 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 checking dimension and metric compatibility.""" + +import asyncio +from typing import Any, Dict, List + +from analytics_mcp.tools.utils import ( + construct_property_rn, + proto_to_dict, +) +from analytics_mcp.tools.client import create_data_api_client +from google.analytics import data_v1beta + + +async def check_compatibility( + property_id: int | str, + dimensions: List[str] = None, + metrics: List[str] = None, + dimension_filter: Dict[str, Any] = None, + metric_filter: Dict[str, Any] = None, + compatibility_filter: str = None, +) -> Dict[str, Any]: + """Checks which dimensions and metrics can be added to a report. + + Use this before `run_report` when combining several dimensions and + metrics, to avoid wasted report requests with incompatible field + combinations. The check is fast and consumes minimal quota. + + The response lists dimensions and metrics with their compatibility: + `COMPATIBLE` fields can be added to a report containing the + requested fields; `INCOMPATIBLE` fields cannot. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + dimensions: The dimensions already in the report, e.g. + `["country", "city"]`. May be empty or omitted. + metrics: The metrics already in the report, e.g. + `["activeUsers"]`. May be empty or omitted. + dimension_filter: A Data API FilterExpression + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression) + applied to the dimensions in the planned report. + metric_filter: A Data API FilterExpression applied to the + metrics in the planned report. + compatibility_filter: Optionally restrict the response to a + single compatibility level. One of `"COMPATIBLE"` or + `"INCOMPATIBLE"`. Filtering to `"COMPATIBLE"` is recommended + since the full response can be large. + """ + request = data_v1beta.CheckCompatibilityRequest( + property=construct_property_rn(property_id), + dimensions=[data_v1beta.Dimension(name=d) for d in (dimensions or [])], + metrics=[data_v1beta.Metric(name=m) for m in (metrics or [])], + ) + + if dimension_filter: + request.dimension_filter = data_v1beta.FilterExpression( + dimension_filter + ) + + if metric_filter: + request.metric_filter = data_v1beta.FilterExpression(metric_filter) + + if compatibility_filter: + try: + request.compatibility_filter = data_v1beta.Compatibility[ + compatibility_filter.upper() + ] + except KeyError: + raise ValueError( + "compatibility_filter must be 'COMPATIBLE' or " + f"'INCOMPATIBLE'. Got '{compatibility_filter}'." + ) + + def _sync_call(): + return create_data_api_client().check_compatibility(request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/analytics_mcp/tools/reporting/pivot.py b/analytics_mcp/tools/reporting/pivot.py new file mode 100644 index 00000000..82a8805b --- /dev/null +++ b/analytics_mcp/tools/reporting/pivot.py @@ -0,0 +1,358 @@ +# 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 pivot reports using the Data API.""" + +import asyncio +from typing import Any, Dict, List + +from analytics_mcp.tools.reporting.metadata import ( + get_date_ranges_hints, + get_dimension_filter_hints, + get_metric_filter_hints, +) +from analytics_mcp.tools.utils import ( + construct_property_rn, + proto_to_dict, + proto_to_json, +) +from analytics_mcp.tools.client import create_data_api_client +from google.analytics import data_v1beta + + +def _get_pivots_hints() -> str: + """Returns hints and examples for pivots arguments.""" + pivot_country = data_v1beta.Pivot( + field_names=["country"], + limit=10, + ) + pivot_device = data_v1beta.Pivot( + field_names=["deviceCategory"], + limit=5, + order_bys=[ + data_v1beta.OrderBy( + metric=data_v1beta.OrderBy.MetricOrderBy( + metric_name="activeUsers" + ), + desc=True, + ) + ], + ) + pivot_aggregated = data_v1beta.Pivot( + field_names=["eventName"], + limit=20, + metric_aggregations=["TOTAL"], + ) + + return f"""Example pivots arguments: + + 1. A single pivot on 'country': + [ {proto_to_json(pivot_country)} ] + + 2. Cross-tabulate 'country' by 'deviceCategory', ordering the + device columns by descending active users: + [ + {proto_to_json(pivot_country)}, + {proto_to_json(pivot_device)} + ] + + 3. A pivot with metric aggregations (totals): + [ {proto_to_json(pivot_aggregated)} ] + + Each pivot's `field_names` must be a subset of the report request's + `dimensions`. A `limit` is required in practice: the product of all + pivots' limits must not exceed 250,000, and each pivot's limit + defaults to a large value if omitted. + """ + + +def _run_pivot_report_description() -> str: + """Returns the description for the `run_pivot_report` tool.""" + return f""" + {run_pivot_report.__doc__} + + ## Hints for arguments + + ### Hints for `dimensions` + + 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. + 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. + + ### 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. + 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 custom metrics for a property. + + ### Hints for `pivots`: + {_get_pivots_hints()} + + ### Hints for `date_ranges`: + {get_date_ranges_hints()} + + ### Hints for `dimension_filter`: + {get_dimension_filter_hints()} + + ### Hints for `metric_filter`: + {get_metric_filter_hints()} + + """ + + +async def run_pivot_report( + property_id: int | str, + date_ranges: List[Dict[str, Any]], + dimensions: List[str], + metrics: List[str], + pivots: List[Dict[str, Any]], + dimension_filter: Dict[str, Any] = None, + metric_filter: Dict[str, Any] = None, + currency_code: str = None, + keep_empty_rows: bool = False, + return_property_quota: bool = False, +) -> Dict[str, Any]: + """Runs a Google Analytics Data API pivot report. + + Pivot reports cross-tabulate dimensions, e.g. country x device + category, without client-side post-processing. Use `run_report` + instead for flat, non-pivoted tables. + + 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. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + date_ranges: A list of date ranges + (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. Every + dimension referenced by a pivot must be listed here. + metrics: A list of metrics to include in the report. + pivots: A list of Data API Pivot + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/runPivotReport#Pivot) + objects describing the visual layout of the report's + dimension columns and rows. Each pivot must contain + `field_names` (a subset of `dimensions`) and should contain a + `limit`. The product of all pivots' limits must not exceed + 250,000. + dimension_filter: A Data API FilterExpression + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression) + to apply to the dimensions. Must not contain metrics. + metric_filter: A Data API FilterExpression to apply to the + metrics. Must not contain dimensions. + currency_code: An ISO4217 currency code (e.g. "USD"). + keep_empty_rows: Whether to include rows whose metrics are all + zero. + return_property_quota: Whether to return property quota + information in the response. + """ + request = _build_pivot_report_request( + construct_property_rn(property_id), + { + "date_ranges": date_ranges, + "dimensions": dimensions, + "metrics": metrics, + "pivots": pivots, + "dimension_filter": dimension_filter, + "metric_filter": metric_filter, + "currency_code": currency_code, + "keep_empty_rows": keep_empty_rows, + "return_property_quota": return_property_quota, + }, + ) + + def _sync_call(): + return create_data_api_client().run_pivot_report(request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) + + +def _validate_pivot_report_spec(spec: Dict[str, Any], label: str) -> None: + """Validates a pivot report specification dict. + + Args: + spec: The report specification to validate. + label: A label identifying the spec in error messages. + """ + for key in ("dimensions", "metrics", "date_ranges", "pivots"): + if not spec.get(key): + raise ValueError(f"{label} is missing required key '{key}'.") + if not isinstance(spec[key], list): + raise ValueError(f"{label} '{key}' must be a list.") + + for i, pivot in enumerate(spec["pivots"]): + if not isinstance(pivot, dict): + raise ValueError(f"{label} pivot {i + 1} must be a dictionary.") + if not pivot.get("field_names"): + raise ValueError( + f"{label} pivot {i + 1} is missing required key " + "'field_names'." + ) + + +def _build_pivot_report_request( + property_rn: str, spec: Dict[str, Any] +) -> data_v1beta.RunPivotReportRequest: + """Builds a RunPivotReportRequest proto from a specification dict. + + Args: + property_rn: The property resource name (e.g. "properties/12345"). + spec: A dict with keys matching the `run_pivot_report` tool's + parameters: `date_ranges`, `dimensions`, `metrics`, + `pivots`, and optionally `dimension_filter`, + `metric_filter`, `currency_code`, `keep_empty_rows`, + `return_property_quota`. + + Returns: + A RunPivotReportRequest proto. + """ + _validate_pivot_report_spec(spec, "Request") + + request = data_v1beta.RunPivotReportRequest( + property=property_rn, + dimensions=[data_v1beta.Dimension(name=d) for d in spec["dimensions"]], + metrics=[data_v1beta.Metric(name=m) for m in spec["metrics"]], + date_ranges=[data_v1beta.DateRange(dr) for dr in spec["date_ranges"]], + pivots=[data_v1beta.Pivot(p) for p in spec["pivots"]], + keep_empty_rows=spec.get("keep_empty_rows", False), + return_property_quota=spec.get("return_property_quota", False), + ) + + dimension_filter = spec.get("dimension_filter") + if dimension_filter: + request.dimension_filter = data_v1beta.FilterExpression( + dimension_filter + ) + + metric_filter = spec.get("metric_filter") + if metric_filter: + request.metric_filter = data_v1beta.FilterExpression(metric_filter) + + currency_code = spec.get("currency_code") + if currency_code: + request.currency_code = currency_code + + return request + + +def _batch_run_pivot_reports_description() -> str: + """Returns the description for the `batch_run_pivot_reports` tool.""" + return f""" + {batch_run_pivot_reports.__doc__} + + ## Hints for arguments + + Each object in the `requests` list uses the same argument + formats as the `run_pivot_report` tool. See that tool's + description for hints on `dimensions`, `metrics`, `pivots`, + `date_ranges`, and filters. + + ### Hints for `pivots` (per request): + {_get_pivots_hints()} + """ + + +async def batch_run_pivot_reports( + property_id: int | str, + requests: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Runs multiple Google Analytics pivot reports in a single request. + + Use this tool instead of calling `run_pivot_report` multiple times + when you need several pivot reports for the same property. This + reduces latency by combining up to 5 pivot reports into one API + call. + + Each object in the `requests` list accepts the same arguments as + the `run_pivot_report` tool. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + requests: A list of 1 to 5 pivot report request objects. Each + object must contain the following required keys: + - `date_ranges`: A list of date ranges to include. + - `dimensions`: A list of dimensions to include. + - `metrics`: A list of metrics to include. + - `pivots`: A list of Pivot objects, each with `field_names` + (a subset of the request's `dimensions`) and a `limit`. + + Each object may also contain the following optional keys: + - `dimension_filter`: A Data API FilterExpression to apply to + the dimensions. + - `metric_filter`: A Data API FilterExpression to apply to + the metrics. + - `currency_code`: An ISO4217 currency code (e.g. "USD"). + - `keep_empty_rows`: Whether to include rows whose metrics + are all zero. + - `return_property_quota`: Whether to return property quota + information in the response. + """ + if not isinstance(requests, list): + raise ValueError("requests must be a list.") + if not requests: + raise ValueError( + "requests must contain at least one pivot report request." + ) + if len(requests) > 5: + raise ValueError( + "requests must contain at most 5 pivot report requests. " + f"Got {len(requests)}." + ) + + for i, spec in enumerate(requests): + if not isinstance(spec, dict): + raise ValueError(f"Request {i + 1} must be a dictionary.") + _validate_pivot_report_spec(spec, f"Request {i + 1}") + + property_rn = construct_property_rn(property_id) + + batch_request = data_v1beta.BatchRunPivotReportsRequest( + property=property_rn, + requests=[ + _build_pivot_report_request(property_rn, spec) for spec in requests + ], + ) + + def _sync_call(): + return create_data_api_client().batch_run_pivot_reports(batch_request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/tests/compatibility_test.py b/tests/compatibility_test.py new file mode 100644 index 00000000..a586e686 --- /dev/null +++ b/tests/compatibility_test.py @@ -0,0 +1,115 @@ +# 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 check_compatibility tool.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1beta + +from analytics_mcp.tools.reporting.compatibility import check_compatibility + + +class TestCheckCompatibility(unittest.TestCase): + """Test cases for check_compatibility.""" + + @patch("analytics_mcp.tools.reporting.compatibility.create_data_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.check_compatibility.return_value = ( + data_v1beta.CheckCompatibilityResponse() + ) + + asyncio.run( + check_compatibility( + 12345, + dimensions=["country", "city"], + metrics=["activeUsers"], + ) + ) + + request = mock_client.check_compatibility.call_args.args[0] + self.assertEqual(request.property, "properties/12345") + self.assertEqual( + [d.name for d in request.dimensions], ["country", "city"] + ) + self.assertEqual([m.name for m in request.metrics], ["activeUsers"]) + + @patch("analytics_mcp.tools.reporting.compatibility.create_data_api_client") + def test_compatibility_filter(self, mock_create_client): + """Tests that the compatibility filter enum is resolved.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.check_compatibility.return_value = ( + data_v1beta.CheckCompatibilityResponse() + ) + + asyncio.run( + check_compatibility( + 12345, + dimensions=["country"], + compatibility_filter="compatible", + ) + ) + + request = mock_client.check_compatibility.call_args.args[0] + self.assertEqual( + request.compatibility_filter, + data_v1beta.Compatibility.COMPATIBLE, + ) + + def test_invalid_compatibility_filter_raises(self): + """Tests that an invalid compatibility filter raises.""" + with self.assertRaises(ValueError): + asyncio.run( + check_compatibility(12345, compatibility_filter="bogus") + ) + + @patch("analytics_mcp.tools.reporting.compatibility.create_data_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.check_compatibility.return_value = ( + data_v1beta.CheckCompatibilityResponse( + dimension_compatibilities=[ + data_v1beta.DimensionCompatibility( + dimension_metadata=data_v1beta.DimensionMetadata( + api_name="country" + ), + compatibility=(data_v1beta.Compatibility.COMPATIBLE), + ) + ] + ) + ) + + result = asyncio.run(check_compatibility(12345)) + + self.assertEqual( + result["dimension_compatibilities"][0]["compatibility"], + "COMPATIBLE", + ) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(check_compatibility("bogus")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/pivot_test.py b/tests/pivot_test.py new file mode 100644 index 00000000..955e8bed --- /dev/null +++ b/tests/pivot_test.py @@ -0,0 +1,214 @@ +# 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_pivot_report tool.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1beta + +from analytics_mcp.tools.reporting.pivot import ( + _run_pivot_report_description, + run_pivot_report, +) + +_BASE_ARGS = { + "date_ranges": [{"start_date": "7daysAgo", "end_date": "today"}], + "dimensions": ["country", "deviceCategory"], + "metrics": ["activeUsers"], + "pivots": [ + {"field_names": ["country"], "limit": 10}, + {"field_names": ["deviceCategory"], "limit": 5}, + ], +} + + +class TestRunPivotReport(unittest.TestCase): + """Test cases for run_pivot_report.""" + + @patch("analytics_mcp.tools.reporting.pivot.create_data_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_pivot_report.return_value = ( + data_v1beta.RunPivotReportResponse() + ) + + asyncio.run(run_pivot_report(12345, **_BASE_ARGS)) + + request = mock_client.run_pivot_report.call_args.args[0] + self.assertIsInstance(request, data_v1beta.RunPivotReportRequest) + self.assertEqual(request.property, "properties/12345") + self.assertEqual( + [d.name for d in request.dimensions], + ["country", "deviceCategory"], + ) + self.assertEqual([m.name for m in request.metrics], ["activeUsers"]) + self.assertEqual(len(request.pivots), 2) + self.assertEqual(list(request.pivots[0].field_names), ["country"]) + self.assertEqual(request.pivots[0].limit, 10) + self.assertEqual( + list(request.pivots[1].field_names), ["deviceCategory"] + ) + self.assertFalse(request.keep_empty_rows) + + @patch("analytics_mcp.tools.reporting.pivot.create_data_api_client") + def test_optional_fields(self, mock_create_client): + """Tests that optional fields are set when provided.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.run_pivot_report.return_value = ( + data_v1beta.RunPivotReportResponse() + ) + + asyncio.run( + run_pivot_report( + 12345, + **_BASE_ARGS, + dimension_filter={ + "filter": { + "field_name": "country", + "string_filter": {"value": "Japan"}, + } + }, + currency_code="USD", + keep_empty_rows=True, + return_property_quota=True, + ) + ) + + request = mock_client.run_pivot_report.call_args.args[0] + self.assertEqual(request.dimension_filter.filter.field_name, "country") + self.assertEqual(request.currency_code, "USD") + self.assertTrue(request.keep_empty_rows) + self.assertTrue(request.return_property_quota) + + def test_empty_pivots_raises(self): + """Tests that an empty pivots list raises a ValueError.""" + args = dict(_BASE_ARGS, pivots=[]) + with self.assertRaises(ValueError): + asyncio.run(run_pivot_report(12345, **args)) + + def test_pivot_missing_field_names_raises(self): + """Tests that a pivot without field_names raises a ValueError.""" + args = dict(_BASE_ARGS, pivots=[{"limit": 5}]) + with self.assertRaises(ValueError): + asyncio.run(run_pivot_report(12345, **args)) + + @patch("analytics_mcp.tools.reporting.pivot.create_data_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_pivot_report.return_value = ( + data_v1beta.RunPivotReportResponse( + aggregates=[], + rows=[ + data_v1beta.Row( + dimension_values=[ + data_v1beta.DimensionValue(value="Japan") + ], + metric_values=[data_v1beta.MetricValue(value="42")], + ) + ], + ) + ) + + result = asyncio.run(run_pivot_report(12345, **_BASE_ARGS)) + + self.assertEqual( + result["rows"][0]["dimension_values"][0]["value"], "Japan" + ) + self.assertEqual(result["rows"][0]["metric_values"][0]["value"], "42") + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(run_pivot_report("bogus", **_BASE_ARGS)) + + +class TestDescription(unittest.TestCase): + """Test cases for the tool description.""" + + def test_description_includes_hints(self): + """Tests that the description contains pivot hints.""" + description = _run_pivot_report_description() + self.assertIn("pivots", description) + self.assertIn("field_names", description) + self.assertIn("date_range", description) + + +if __name__ == "__main__": + unittest.main() + + +class TestBatchRunPivotReports(unittest.TestCase): + """Test cases for batch_run_pivot_reports.""" + + @patch("analytics_mcp.tools.reporting.pivot.create_data_api_client") + def test_builds_batch_request(self, mock_create_client): + """Tests that the batch request proto is built correctly.""" + from analytics_mcp.tools.reporting.pivot import ( + batch_run_pivot_reports, + ) + + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.batch_run_pivot_reports.return_value = ( + data_v1beta.BatchRunPivotReportsResponse() + ) + + asyncio.run( + batch_run_pivot_reports(12345, requests=[_BASE_ARGS, _BASE_ARGS]) + ) + + request = mock_client.batch_run_pivot_reports.call_args.args[0] + self.assertIsInstance(request, data_v1beta.BatchRunPivotReportsRequest) + self.assertEqual(request.property, "properties/12345") + self.assertEqual(len(request.requests), 2) + self.assertEqual(request.requests[0].property, "properties/12345") + self.assertEqual(len(request.requests[0].pivots), 2) + + def test_empty_requests_raises(self): + """Tests that an empty requests list raises a ValueError.""" + from analytics_mcp.tools.reporting.pivot import ( + batch_run_pivot_reports, + ) + + with self.assertRaises(ValueError): + asyncio.run(batch_run_pivot_reports(12345, requests=[])) + + def test_too_many_requests_raises(self): + """Tests that more than 5 requests raises a ValueError.""" + from analytics_mcp.tools.reporting.pivot import ( + batch_run_pivot_reports, + ) + + with self.assertRaises(ValueError): + asyncio.run( + batch_run_pivot_reports(12345, requests=[_BASE_ARGS] * 6) + ) + + def test_request_missing_pivots_raises(self): + """Tests that a request without pivots raises a ValueError.""" + from analytics_mcp.tools.reporting.pivot import ( + batch_run_pivot_reports, + ) + + bad = {k: v for k, v in _BASE_ARGS.items() if k != "pivots"} + with self.assertRaises(ValueError): + asyncio.run(batch_run_pivot_reports(12345, requests=[bad])) From 760f8807ec0cc292861618b1212cd0e7a848458a Mon Sep 17 00:00:00 2001 From: Avicennasis Date: Wed, 19 Aug 2026 13:59:44 -0400 Subject: [PATCH 6/6] feat: add batch_run_reports tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reapplied onto main after #4 (pivot tools) merged — that touched the same coordinator.py registry, so this branch conflicted again. Same four additions at the same anchors; verified the pivot registrations from #4 are still intact (6 references) rather than clobbered. batch_test.py uses asyncio.run() to match the repo's other tests, and black --check -l 80 is clean across all 27 files. --- analytics_mcp/coordinator.py | 15 ++ analytics_mcp/tools/reporting/batch.py | 221 ++++++++++++++++ tests/batch_test.py | 340 +++++++++++++++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 analytics_mcp/tools/reporting/batch.py create mode 100644 tests/batch_test.py diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index 543c7c69..85779bf7 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -63,6 +63,10 @@ validate_event, send_event, ) +from analytics_mcp.tools.reporting.batch import ( + batch_run_reports, + _batch_run_reports_description, +) from analytics_mcp.tools.reporting.compatibility import check_compatibility from analytics_mcp.tools.reporting.pivot import ( run_pivot_report, @@ -94,7 +98,13 @@ _batch_run_pivot_reports_description() ) +batch_run_reports_with_description = FunctionTool(batch_run_reports) +batch_run_reports_with_description.description = ( + _batch_run_reports_description() +) + tools = [ + batch_run_reports_with_description, FunctionTool(check_compatibility), run_pivot_report_with_description, batch_run_pivot_reports_with_description, @@ -172,6 +182,11 @@ def sanitize_mcp_schema_properties(node: dict) -> None: "dimensions", "metrics", ] + elif tool.name == "batch_run_reports": + tool.inputSchema["required"] = [ + "property_id", + "requests", + ] elif tool.name == "check_compatibility": tool.inputSchema["required"] = ["property_id"] elif tool.name == "run_pivot_report": diff --git a/analytics_mcp/tools/reporting/batch.py b/analytics_mcp/tools/reporting/batch.py new file mode 100644 index 00000000..f8082d55 --- /dev/null +++ b/analytics_mcp/tools/reporting/batch.py @@ -0,0 +1,221 @@ +# 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 batch reports using the Data API.""" + +import asyncio +from typing import Any, Dict, List + +from analytics_mcp.tools.reporting.metadata import ( + get_date_ranges_hints, + get_dimension_filter_hints, + get_metric_filter_hints, + get_order_bys_hints, +) +from analytics_mcp.tools.utils import ( + construct_property_rn, + proto_to_dict, +) +from analytics_mcp.tools.client import create_data_api_client +from google.analytics import data_v1beta + + +def _batch_run_reports_description() -> str: + """Returns the description for the `batch_run_reports` tool.""" + return f""" + {batch_run_reports.__doc__} + + ## Hints for arguments + + Here are some hints that outline the expected format and + requirements for arguments. Each object in the `requests` + list uses the same argument formats as the `run_report` tool. + + ### Hints for `dimensions` + + 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. + 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. + + ### 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. + 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 custom metrics for a property. + + + ### Hints for `date_ranges`: + {get_date_ranges_hints()} + + ### Hints for `dimension_filter`: + {get_dimension_filter_hints()} + + ### Hints for `metric_filter`: + {get_metric_filter_hints()} + + ### Hints for `order_bys`: + {get_order_bys_hints()} + + """ + + +def _build_report_request( + property_rn: str, report: Dict[str, Any] +) -> data_v1beta.RunReportRequest: + """Builds a RunReportRequest proto from a report specification dict. + + Args: + property_rn: The property resource name (e.g. "properties/12345"). + report: A dict with keys matching the `run_report` tool's + parameters: `dimensions`, `metrics`, `date_ranges`, and + optionally `dimension_filter`, `metric_filter`, `order_bys`, + `limit`, `offset`, `currency_code`, `return_property_quota`. + + Returns: + A RunReportRequest proto. + """ + request = data_v1beta.RunReportRequest( + property=property_rn, + dimensions=[ + data_v1beta.Dimension(name=d) for d in report["dimensions"] + ], + metrics=[data_v1beta.Metric(name=m) for m in report["metrics"]], + date_ranges=[data_v1beta.DateRange(dr) for dr in report["date_ranges"]], + return_property_quota=report.get("return_property_quota", False), + ) + + dimension_filter = report.get("dimension_filter") + if dimension_filter: + request.dimension_filter = data_v1beta.FilterExpression( + dimension_filter + ) + + metric_filter = report.get("metric_filter") + if metric_filter: + request.metric_filter = data_v1beta.FilterExpression(metric_filter) + + order_bys = report.get("order_bys") + if order_bys: + request.order_bys = [data_v1beta.OrderBy(ob) for ob in order_bys] + + limit = report.get("limit") + if limit: + request.limit = limit + + offset = report.get("offset") + if offset: + request.offset = offset + + currency_code = report.get("currency_code") + if currency_code: + request.currency_code = currency_code + + return request + + +async def batch_run_reports( + property_id: int | str, + requests: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Runs multiple Google Analytics Data API reports in a single request. + + Use this tool instead of calling `run_report` multiple times when you + need data from several reports for the same property. This reduces + latency by combining up to 5 reports into one API call. + + Each object in the `requests` list accepts the same arguments as the + `run_report` tool. + + 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. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + requests: A list of 1 to 5 report request objects. Each object + must contain the following required keys: + - `dimensions`: A list of dimensions to include in the report. + - `metrics`: A list of metrics to include in the report. + - `date_ranges`: A list of date ranges + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/DateRange) + to include in the report. + + Each object may also contain the following optional keys: + - `dimension_filter`: A Data API FilterExpression + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression) + to apply to the dimensions. + - `metric_filter`: A Data API FilterExpression to apply to the + metrics. + - `order_bys`: A list of Data API OrderBy + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/OrderBy) + objects. + - `limit`: The maximum number of rows to return (max 250,000). + - `offset`: The row count of the start row (0-indexed). + - `currency_code`: An ISO4217 currency code (e.g. "USD"). + - `return_property_quota`: Whether to return property quota + information in the response (default: false). + """ + if not isinstance(requests, list): + raise ValueError("requests must be a list.") + if not requests: + raise ValueError("requests must contain at least one report request.") + if len(requests) > 5: + raise ValueError( + "requests must contain at most 5 report requests. " + f"Got {len(requests)}." + ) + + for i, report in enumerate(requests): + if not isinstance(report, dict): + raise ValueError(f"Request {i + 1} must be a dictionary.") + for key in ("dimensions", "metrics", "date_ranges"): + if key not in report: + raise ValueError( + f"Request {i + 1} is missing required key " f"'{key}'." + ) + if not isinstance(report[key], list): + raise ValueError(f"Request {i + 1} '{key}' must be a list.") + + property_rn = construct_property_rn(property_id) + + batch_request = data_v1beta.BatchRunReportsRequest( + property=property_rn, + requests=[_build_report_request(property_rn, r) for r in requests], + ) + + def _sync_call(): + return create_data_api_client().batch_run_reports(batch_request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/tests/batch_test.py b/tests/batch_test.py new file mode 100644 index 00000000..3498a82a --- /dev/null +++ b/tests/batch_test.py @@ -0,0 +1,340 @@ +# 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 batch_run_reports tool.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1beta + +from analytics_mcp.tools.reporting.batch import ( + batch_run_reports, + _build_report_request, +) + + +class TestBuildReportRequest(unittest.TestCase): + """Test cases for _build_report_request.""" + + def test_required_fields(self): + """Tests that required fields are set correctly.""" + report = { + "dimensions": ["country", "city"], + "metrics": ["activeUsers", "sessions"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + request = _build_report_request("properties/12345", report) + + self.assertIsInstance(request, data_v1beta.RunReportRequest) + self.assertEqual(request.property, "properties/12345") + self.assertEqual(len(request.dimensions), 2) + self.assertEqual(request.dimensions[0].name, "country") + self.assertEqual(request.dimensions[1].name, "city") + self.assertEqual(len(request.metrics), 2) + self.assertEqual(request.metrics[0].name, "activeUsers") + self.assertEqual(request.metrics[1].name, "sessions") + self.assertEqual(len(request.date_ranges), 1) + self.assertFalse(request.return_property_quota) + + def test_optional_fields(self): + """Tests that optional fields are set when provided.""" + report = { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + "limit": 100, + "offset": 50, + "currency_code": "USD", + "return_property_quota": True, + } + request = _build_report_request("properties/12345", report) + + self.assertEqual(request.limit, 100) + self.assertEqual(request.offset, 50) + self.assertEqual(request.currency_code, "USD") + self.assertTrue(request.return_property_quota) + + def test_dimension_filter(self): + """Tests that dimension_filter is set when provided.""" + report = { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + "dimension_filter": { + "filter": { + "field_name": "country", + "string_filter": { + "match_type": "EXACT", + "value": "US", + }, + } + }, + } + request = _build_report_request("properties/12345", report) + + self.assertIsNotNone(request.dimension_filter) + + def test_metric_filter(self): + """Tests that metric_filter is set when provided.""" + report = { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + "metric_filter": { + "filter": { + "field_name": "activeUsers", + "numeric_filter": { + "operation": "GREATER_THAN", + "value": {"int64_value": 10}, + }, + } + }, + } + request = _build_report_request("properties/12345", report) + + self.assertIsNotNone(request.metric_filter) + + def test_order_bys(self): + """Tests that order_bys are set when provided.""" + report = { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + "order_bys": [ + { + "metric": { + "metric_name": "activeUsers", + }, + "desc": True, + } + ], + } + request = _build_report_request("properties/12345", report) + + self.assertEqual(len(request.order_bys), 1) + + def test_optional_fields_absent(self): + """Tests that optional fields are absent when not provided.""" + report = { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + request = _build_report_request("properties/12345", report) + + self.assertEqual(request.limit, 0) + self.assertEqual(request.offset, 0) + self.assertEqual(request.currency_code, "") + self.assertEqual(len(request.order_bys), 0) + + +class TestBatchRunReports(unittest.TestCase): + """Test cases for batch_run_reports validation.""" + + def test_empty_requests_raises(self): + """Tests that an empty requests list raises ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, [])) + + def test_too_many_requests_raises(self): + """Tests that more than 5 requests raises ValueError.""" + reports = [ + { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + ] * 6 + + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, reports)) + + def test_requests_not_a_list_raises(self): + """Tests that a non-list requests value raises ValueError.""" + with self.assertRaises(ValueError): + asyncio.run( + batch_run_reports( + 12345, + { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + }, + ) + ) + + def test_non_dict_request_raises(self): + """Tests that a non-dict request raises ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, ["not a dict"])) + + def test_dimensions_not_a_list_raises(self): + """Tests that a non-list dimensions value raises ValueError.""" + reports = [ + { + "dimensions": "country", + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + ] + + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, reports)) + + def test_missing_dimensions_raises(self): + """Tests that a request missing dimensions raises ValueError.""" + reports = [ + { + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + ] + + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, reports)) + + def test_missing_metrics_raises(self): + """Tests that a request missing metrics raises ValueError.""" + reports = [ + { + "dimensions": ["country"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + ] + + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, reports)) + + def test_missing_date_ranges_raises(self): + """Tests that a request missing date_ranges raises ValueError.""" + reports = [ + { + "dimensions": ["country"], + "metrics": ["activeUsers"], + } + ] + + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, reports)) + + @patch("analytics_mcp.tools.reporting.batch." "create_data_api_client") + def test_api_called_with_correct_request(self, mock_client): + """Tests that the API is called with the correct request.""" + mock_response = MagicMock() + mock_response.__class__ = data_v1beta.BatchRunReportsResponse + mock_client_instance = MagicMock() + mock_client_instance.batch_run_reports.return_value = mock_response + mock_client.return_value = mock_client_instance + + reports = [ + { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + }, + { + "dimensions": ["city"], + "metrics": ["sessions"], + "date_ranges": [ + { + "start_date": "2025-02-01", + "end_date": "2025-02-28", + } + ], + }, + ] + + with patch( + "analytics_mcp.tools.reporting.batch.proto_to_dict", + return_value={"reports": []}, + ): + result = asyncio.run(batch_run_reports(12345, reports)) + + mock_client_instance.batch_run_reports.assert_called_once() + call_args = mock_client_instance.batch_run_reports.call_args + batch_request = call_args[0][0] + + self.assertEqual(batch_request.property, "properties/12345") + self.assertEqual(len(batch_request.requests), 2) + self.assertEqual( + batch_request.requests[0].dimensions[0].name, + "country", + ) + self.assertEqual( + batch_request.requests[1].dimensions[0].name, + "city", + ) + self.assertEqual(result, {"reports": []})