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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 44 additions & 21 deletions analytics_mcp/tools/admin/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,33 @@

"""Tools for gathering Google Analytics account and property information."""

import asyncio
import sys
from typing import Any, Dict, List

from analytics_mcp.tools.utils import (
construct_property_rn,
create_admin_api_client,
create_admin_api_client_sync,
create_admin_alpha_api_client,
create_admin_alpha_api_client_sync,
proto_to_dict,
)
from google.analytics import admin_v1beta, admin_v1alpha


async def get_account_summaries() -> List[Dict[str, Any]]:
"""Retrieves information about the user's Google Analytics accounts and properties."""
if sys.platform == "win32":
def _fetch():
client = create_admin_api_client_sync()
summary_pager = client.list_account_summaries()
return [proto_to_dict(summary_page) for summary_page in summary_pager]
return await asyncio.to_thread(_fetch)

# Uses an async list comprehension so the pager returned by
# list_account_summaries retrieves all pages.
summary_pager = await create_admin_api_client().list_account_summaries()
all_pages = [
proto_to_dict(summary_page) async for summary_page in summary_pager
]
return all_pages
client = create_admin_api_client()
summary_pager = await client.list_account_summaries()
return [proto_to_dict(summary_page) for summary_page in summary_pager]


async def list_google_ads_links(property_id: int | str) -> List[Dict[str, Any]]:
Expand All @@ -48,13 +54,19 @@ async def list_google_ads_links(property_id: int | str) -> List[Dict[str, Any]]:
request = admin_v1beta.ListGoogleAdsLinksRequest(
parent=construct_property_rn(property_id)
)
# Uses an async list comprehension so the pager returned by
# list_google_ads_links retrieves all pages.

if sys.platform == "win32":
def _fetch():
links_pager = create_admin_api_client_sync().list_google_ads_links(
request=request
)
return [proto_to_dict(link_page) for link_page in links_pager]
return await asyncio.to_thread(_fetch)

links_pager = await create_admin_api_client().list_google_ads_links(
request=request
)
all_pages = [proto_to_dict(link_page) async for link_page in links_pager]
return all_pages
return [proto_to_dict(link_page) for link_page in links_pager]


async def get_property_details(property_id: int | str) -> Dict[str, Any]:
Expand All @@ -64,10 +76,18 @@ async def get_property_details(property_id: int | str) -> Dict[str, Any]:
- A number
- A string consisting of 'properties/' followed by a number
"""
client = create_admin_api_client()
request = admin_v1beta.GetPropertyRequest(
name=construct_property_rn(property_id)
)

if sys.platform == "win32":
def _fetch():
client = create_admin_api_client_sync()
response = client.get_property(request=request)
return proto_to_dict(response)
return await asyncio.to_thread(_fetch)

client = create_admin_api_client()
response = await client.get_property(request=request)
return proto_to_dict(response)

Expand All @@ -89,13 +109,16 @@ async def list_property_annotations(
request = admin_v1alpha.ListReportingDataAnnotationsRequest(
parent=construct_property_rn(property_id)
)
annotations_pager = (
await create_admin_alpha_api_client().list_reporting_data_annotations(
request=request
)

if sys.platform == "win32":
def _fetch():
annotations_pager = create_admin_alpha_api_client_sync().list_reporting_data_annotations(
request=request
)
return [proto_to_dict(annotation_page) for annotation_page in annotations_pager]
return await asyncio.to_thread(_fetch)

annotations_pager = await create_admin_alpha_api_client().list_reporting_data_annotations(
request=request
)
all_pages = [
proto_to_dict(annotation_page)
async for annotation_page in annotations_pager
]
return all_pages
return [proto_to_dict(annotation_page) for annotation_page in annotations_pager]
11 changes: 10 additions & 1 deletion analytics_mcp/tools/reporting/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

"""Tools for running core reports using the Data API."""

import asyncio
import sys
from typing import Any, Dict, List

from analytics_mcp.tools.reporting.metadata import (
Expand All @@ -25,6 +27,7 @@
from analytics_mcp.tools.utils import (
construct_property_rn,
create_data_api_client,
create_data_api_client_sync,
proto_to_dict,
)
from google.analytics import data_v1beta
Expand Down Expand Up @@ -167,6 +170,12 @@ async def run_report(
if currency_code:
request.currency_code = currency_code

response = await create_data_api_client().run_report(request)
if sys.platform == "win32":
def _fetch():
return proto_to_dict(
create_data_api_client_sync().run_report(request)
)
return await asyncio.to_thread(_fetch)

response = await create_data_api_client().run_report(request)
return proto_to_dict(response)
24 changes: 24 additions & 0 deletions analytics_mcp/tools/reporting/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@

"""Metadata to provide context and hints for reporting tools."""

import asyncio
import sys
from typing import Any, Dict, List

from analytics_mcp.tools.utils import (
construct_property_rn,
create_data_api_client,
create_data_api_client_sync,
proto_to_dict,
proto_to_json,
)
Expand Down Expand Up @@ -319,6 +322,27 @@ async def get_custom_dimensions_and_metrics(
- A string consisting of 'properties/' followed by a number

"""
if sys.platform == "win32":
def _fetch():
metadata = create_data_api_client_sync().get_metadata(
name=f"{construct_property_rn(property_id)}/metadata"
)
custom_metrics = [
proto_to_dict(metric)
for metric in metadata.metrics
if metric.custom_definition
]
custom_dimensions = [
proto_to_dict(dimension)
for dimension in metadata.dimensions
if dimension.custom_definition
]
return {
"custom_dimensions": custom_dimensions,
"custom_metrics": custom_metrics,
}
return await asyncio.to_thread(_fetch)

metadata = await create_data_api_client().get_metadata(
name=f"{construct_property_rn(property_id)}/metadata"
)
Expand Down
10 changes: 10 additions & 0 deletions analytics_mcp/tools/reporting/realtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@

"""Tools for running realtime reports using the Data API."""

import asyncio
import sys
from typing import Any, Dict, List

from analytics_mcp.tools.utils import (
construct_property_rn,
create_data_api_client,
create_data_api_client_sync,
proto_to_dict,
)
from analytics_mcp.tools.reporting.metadata import (
Expand Down Expand Up @@ -157,5 +160,12 @@ async def run_realtime_report(
if offset:
request.offset = offset

if sys.platform == "win32":
def _fetch():
return proto_to_dict(
create_data_api_client_sync().run_realtime_report(request)
)
return await asyncio.to_thread(_fetch)

response = await create_data_api_client().run_realtime_report(request)
return proto_to_dict(response)
52 changes: 51 additions & 1 deletion analytics_mcp/tools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@

"""Common utilities used by the MCP server."""

import os
import sys
from typing import Any, Dict

from google.analytics import admin_v1beta, data_v1beta, admin_v1alpha
from google.api_core.gapic_v1.client_info import ClientInfo
from importlib import metadata
import google.auth
import google.oauth2.credentials
import proto


Expand All @@ -46,11 +49,58 @@ def _get_package_version_with_fallback():


def _create_credentials() -> google.auth.credentials.Credentials:
"""Returns Application Default Credentials with read-only scope."""
"""Returns Application Default Credentials with read-only scope.

On Windows, prefers loading credentials directly from the file specified
by GOOGLE_APPLICATION_CREDENTIALS to avoid subprocess spawning, which can
interfere with the MCP server's stdio transport.
"""
if sys.platform == "win32":
path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
if path and os.path.exists(path):
return google.oauth2.credentials.Credentials.from_authorized_user_file(
path, scopes=[_READ_ONLY_ANALYTICS_SCOPE]
)
credentials, _ = google.auth.default(scopes=[_READ_ONLY_ANALYTICS_SCOPE])
return credentials


def create_admin_api_client_sync() -> admin_v1beta.AnalyticsAdminServiceClient:
"""Returns a properly configured Google Analytics Admin API sync client.

Uses Application Default Credentials with read-only scope.
On Windows, this sync client is used instead of the async variant to
avoid grpc.aio event loop conflicts with the MCP stdio transport.
"""
return admin_v1beta.AnalyticsAdminServiceClient(
client_info=_CLIENT_INFO, credentials=_create_credentials()
)


def create_data_api_client_sync() -> data_v1beta.BetaAnalyticsDataClient:
"""Returns a properly configured Google Analytics Data API sync client.

Uses Application Default Credentials with read-only scope.
On Windows, this sync client is used instead of the async variant to
avoid grpc.aio event loop conflicts with the MCP stdio transport.
"""
return data_v1beta.BetaAnalyticsDataClient(
client_info=_CLIENT_INFO, credentials=_create_credentials()
)


def create_admin_alpha_api_client_sync() -> admin_v1alpha.AnalyticsAdminServiceClient:
"""Returns a properly configured Google Analytics Admin API (alpha) sync client.

Uses Application Default Credentials with read-only scope.
On Windows, this sync client is used instead of the async variant to
avoid grpc.aio event loop conflicts with the MCP stdio transport.
"""
return admin_v1alpha.AnalyticsAdminServiceClient(
client_info=_CLIENT_INFO, credentials=_create_credentials()
)


def create_admin_api_client() -> admin_v1beta.AnalyticsAdminServiceAsyncClient:
"""Returns a properly configured Google Analytics Admin API async client.

Expand Down