diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index a222420..5fd5b7b 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -14,12 +14,16 @@ """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 @@ -27,14 +31,16 @@ 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]]: @@ -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]: @@ -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) @@ -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] diff --git a/analytics_mcp/tools/reporting/core.py b/analytics_mcp/tools/reporting/core.py index f8fd5ea..d5fdf1e 100644 --- a/analytics_mcp/tools/reporting/core.py +++ b/analytics_mcp/tools/reporting/core.py @@ -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 ( @@ -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 @@ -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) diff --git a/analytics_mcp/tools/reporting/metadata.py b/analytics_mcp/tools/reporting/metadata.py index 22f5e13..cc63503 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -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, ) @@ -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" ) diff --git a/analytics_mcp/tools/reporting/realtime.py b/analytics_mcp/tools/reporting/realtime.py index 8c77ed2..ae1f157 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -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 ( @@ -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) diff --git a/analytics_mcp/tools/utils.py b/analytics_mcp/tools/utils.py index 0f35040..7a5d890 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -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 @@ -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.