From e96ad46d5d3cacdb39d277892b71126ea37c3988 Mon Sep 17 00:00:00 2001 From: gitrishiom Date: Sun, 19 Apr 2026 04:50:33 +0100 Subject: [PATCH 1/2] fix: resolve Windows stdio deadlocks caused by grpc.aio and google.auth - Replaced grpc.aio async clients with synchronous variants offloaded via asyncio.to_thread to prevent transport starvation on Windows pipes. - Provided fallback logic for google.auth.default() to prevent blocking subprocess spawns during credential resolution. --- analytics_mcp/tools/admin/info.py | 76 ++++++++++++----------- analytics_mcp/tools/reporting/core.py | 60 +++++++++--------- analytics_mcp/tools/reporting/metadata.py | 39 ++++++------ analytics_mcp/tools/reporting/realtime.py | 54 ++++++++-------- analytics_mcp/tools/utils.py | 50 ++++++++------- 5 files changed, 148 insertions(+), 131 deletions(-) diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index a222420..ae9e084 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -18,8 +18,8 @@ from analytics_mcp.tools.utils import ( construct_property_rn, - create_admin_api_client, - create_admin_alpha_api_client, + create_admin_api_client_sync, + create_admin_alpha_api_client_sync, proto_to_dict, ) from google.analytics import admin_v1beta, admin_v1alpha @@ -27,14 +27,13 @@ async def get_account_summaries() -> List[Dict[str, Any]]: """Retrieves information about the user's Google Analytics accounts and properties.""" - - # Uses an async list comprehension so the pager returned by - # list_account_summaries retrieves all pages. - summary_pager = await create_admin_api_client().list_account_summaries() - all_pages = [ - proto_to_dict(summary_page) async for summary_page in summary_pager - ] - return all_pages + import asyncio + 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) async def list_google_ads_links(property_id: int | str) -> List[Dict[str, Any]]: @@ -45,16 +44,17 @@ async def list_google_ads_links(property_id: int | str) -> List[Dict[str, Any]]: - A number - A string consisting of 'properties/' followed by a number """ - 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. - 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 + import asyncio + def _fetch(): + request = admin_v1beta.ListGoogleAdsLinksRequest( + parent=construct_property_rn(property_id) + ) + 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) async def get_property_details(property_id: int | str) -> Dict[str, Any]: @@ -64,12 +64,16 @@ 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) - ) - response = await client.get_property(request=request) - return proto_to_dict(response) + import asyncio + def _fetch(): + client = create_admin_api_client_sync() + request = admin_v1beta.GetPropertyRequest( + name=construct_property_rn(property_id) + ) + response = client.get_property(request=request) + return proto_to_dict(response) + + return await asyncio.to_thread(_fetch) async def list_property_annotations( @@ -86,16 +90,14 @@ async def list_property_annotations( - A number - A string consisting of 'properties/' followed by a number """ - request = admin_v1alpha.ListReportingDataAnnotationsRequest( - parent=construct_property_rn(property_id) - ) - annotations_pager = ( - await create_admin_alpha_api_client().list_reporting_data_annotations( + import asyncio + def _fetch(): + request = admin_v1alpha.ListReportingDataAnnotationsRequest( + parent=construct_property_rn(property_id) + ) + annotations_pager = create_admin_alpha_api_client_sync().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] + + return await asyncio.to_thread(_fetch) diff --git a/analytics_mcp/tools/reporting/core.py b/analytics_mcp/tools/reporting/core.py index f8fd5ea..416f30b 100644 --- a/analytics_mcp/tools/reporting/core.py +++ b/analytics_mcp/tools/reporting/core.py @@ -24,7 +24,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 @@ -137,36 +137,40 @@ async def run_report( report uses the property's default currency. return_property_quota: Whether to return property quota in the response. """ - request = data_v1beta.RunReportRequest( - property=construct_property_rn(property_id), - dimensions=[ - data_v1beta.Dimension(name=dimension) for dimension in dimensions - ], - metrics=[data_v1beta.Metric(name=metric) for metric in metrics], - date_ranges=[data_v1beta.DateRange(dr) for dr in date_ranges], - return_property_quota=return_property_quota, - ) - - if dimension_filter: - request.dimension_filter = data_v1beta.FilterExpression( - dimension_filter + import asyncio + def _fetch(): + request = data_v1beta.RunReportRequest( + property=construct_property_rn(property_id), + dimensions=[ + data_v1beta.Dimension(name=dimension) for dimension in dimensions + ], + metrics=[data_v1beta.Metric(name=metric) for metric in metrics], + date_ranges=[data_v1beta.DateRange(dr) for dr in date_ranges], + return_property_quota=return_property_quota, ) - if metric_filter: - request.metric_filter = data_v1beta.FilterExpression(metric_filter) + if dimension_filter: + request.dimension_filter = data_v1beta.FilterExpression( + dimension_filter + ) - if order_bys: - request.order_bys = [ - data_v1beta.OrderBy(order_by) for order_by in order_bys - ] + if metric_filter: + request.metric_filter = data_v1beta.FilterExpression(metric_filter) - if limit: - request.limit = limit - if offset: - request.offset = offset - if currency_code: - request.currency_code = currency_code + if order_bys: + request.order_bys = [ + data_v1beta.OrderBy(order_by) for order_by in order_bys + ] - response = await create_data_api_client().run_report(request) + if limit: + request.limit = limit + if offset: + request.offset = offset + if currency_code: + request.currency_code = currency_code - return proto_to_dict(response) + response = create_data_api_client_sync().run_report(request) + + return proto_to_dict(response) + + return await asyncio.to_thread(_fetch) diff --git a/analytics_mcp/tools/reporting/metadata.py b/analytics_mcp/tools/reporting/metadata.py index 22f5e13..ec72a96 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -18,7 +18,7 @@ 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,20 +319,23 @@ async def get_custom_dimensions_and_metrics( - A string consisting of 'properties/' followed by a number """ - metadata = await create_data_api_client().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, - } + import asyncio + 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) diff --git a/analytics_mcp/tools/reporting/realtime.py b/analytics_mcp/tools/reporting/realtime.py index 8c77ed2..99ac7fc 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -18,7 +18,7 @@ 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 ( @@ -130,32 +130,36 @@ async def run_realtime_report( https://developers.google.com/analytics/devguides/reporting/data/v1/basics#pagination. return_property_quota: Whether to return realtime property quota in the response. """ - request = data_v1beta.RunRealtimeReportRequest( - property=construct_property_rn(property_id), - dimensions=[ - data_v1beta.Dimension(name=dimension) for dimension in dimensions - ], - metrics=[data_v1beta.Metric(name=metric) for metric in metrics], - return_property_quota=return_property_quota, - ) - - if dimension_filter: - request.dimension_filter = data_v1beta.FilterExpression( - dimension_filter + import asyncio + def _fetch(): + request = data_v1beta.RunRealtimeReportRequest( + property=construct_property_rn(property_id), + dimensions=[ + data_v1beta.Dimension(name=dimension) for dimension in dimensions + ], + metrics=[data_v1beta.Metric(name=metric) for metric in metrics], + return_property_quota=return_property_quota, ) - if metric_filter: - request.metric_filter = data_v1beta.FilterExpression(metric_filter) + if dimension_filter: + request.dimension_filter = data_v1beta.FilterExpression( + dimension_filter + ) - if order_bys: - request.order_bys = [ - data_v1beta.OrderBy(order_by) for order_by in order_bys - ] + if metric_filter: + request.metric_filter = data_v1beta.FilterExpression(metric_filter) - if limit: - request.limit = limit - if offset: - request.offset = offset + if order_bys: + request.order_bys = [ + data_v1beta.OrderBy(order_by) for order_by in order_bys + ] - response = await create_data_api_client().run_realtime_report(request) - return proto_to_dict(response) + if limit: + request.limit = limit + if offset: + request.offset = offset + + response = create_data_api_client_sync().run_realtime_report(request) + return proto_to_dict(response) + + return await asyncio.to_thread(_fetch) diff --git a/analytics_mcp/tools/utils.py b/analytics_mcp/tools/utils.py index 0f35040..b7784d1 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -47,37 +47,41 @@ def _get_package_version_with_fallback(): def _create_credentials() -> google.auth.credentials.Credentials: """Returns Application Default Credentials with read-only scope.""" - credentials, _ = google.auth.default(scopes=[_READ_ONLY_ANALYTICS_SCOPE]) - return credentials - - -def create_admin_api_client() -> admin_v1beta.AnalyticsAdminServiceAsyncClient: - """Returns a properly configured Google Analytics Admin API async client. - - Uses Application Default Credentials with read-only scope. - """ - return admin_v1beta.AnalyticsAdminServiceAsyncClient( + import sys + import os + import google.oauth2.credentials + print("_create_credentials started", file=sys.stderr) + try: + path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") + if path and os.path.exists(path): + print(f"Loading credentials directly from {path}", file=sys.stderr) + credentials = google.oauth2.credentials.Credentials.from_authorized_user_file(path, scopes=[_READ_ONLY_ANALYTICS_SCOPE]) + print("Credentials loaded successfully from file", file=sys.stderr) + return credentials + else: + print("GOOGLE_APPLICATION_CREDENTIALS not found, falling back to default", file=sys.stderr) + credentials, _ = google.auth.default(scopes=[_READ_ONLY_ANALYTICS_SCOPE]) + print("google.auth.default finished", file=sys.stderr) + return credentials + except Exception as e: + print(f"google.auth.default failed: {e}", file=sys.stderr) + raise + + +def create_admin_api_client_sync() -> admin_v1beta.AnalyticsAdminServiceClient: + return admin_v1beta.AnalyticsAdminServiceClient( client_info=_CLIENT_INFO, credentials=_create_credentials() ) -def create_data_api_client() -> data_v1beta.BetaAnalyticsDataAsyncClient: - """Returns a properly configured Google Analytics Data API async client. - - Uses Application Default Credentials with read-only scope. - """ - return data_v1beta.BetaAnalyticsDataAsyncClient( +def create_data_api_client_sync() -> data_v1beta.BetaAnalyticsDataClient: + return data_v1beta.BetaAnalyticsDataClient( client_info=_CLIENT_INFO, credentials=_create_credentials() ) -def create_admin_alpha_api_client() -> ( - admin_v1alpha.AnalyticsAdminServiceAsyncClient -): - """Returns a properly configured Google Analytics Admin API (alpha) async client. - Uses Application Default Credentials with read-only scope. - """ - return admin_v1alpha.AnalyticsAdminServiceAsyncClient( +def create_admin_alpha_api_client_sync() -> admin_v1alpha.AnalyticsAdminServiceClient: + return admin_v1alpha.AnalyticsAdminServiceClient( client_info=_CLIENT_INFO, credentials=_create_credentials() ) From 08a5dc06d4541741d37fb73ad558978056a176d8 Mon Sep 17 00:00:00 2001 From: gitrishiom Date: Sun, 26 Apr 2026 22:42:25 +0100 Subject: [PATCH 2/2] refactor: address reviewer feedback on Windows stdio deadlock fix - Remove all debug print() statements from _create_credentials (blocker) - Move import asyncio, sys, os, google.oauth2.credentials to top-level imports - Restore docstrings on all client factory functions - Keep both sync and async client factories; async clients remain for Linux/macOS - Apply Windows-only workaround via sys.platform == 'win32' conditional: uses sync client + asyncio.to_thread on Windows, native async on other platforms - Avoids thread-pool overhead on Linux/macOS where grpc.aio works correctly --- analytics_mcp/tools/admin/info.py | 99 ++++++++++++++--------- analytics_mcp/tools/reporting/core.py | 71 ++++++++-------- analytics_mcp/tools/reporting/metadata.py | 61 +++++++++----- analytics_mcp/tools/reporting/realtime.py | 60 +++++++------- analytics_mcp/tools/utils.py | 82 ++++++++++++++----- 5 files changed, 236 insertions(+), 137 deletions(-) diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index ae9e084..5fd5b7b 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -14,11 +14,15 @@ """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, ) @@ -27,13 +31,16 @@ async def get_account_summaries() -> List[Dict[str, Any]]: """Retrieves information about the user's Google Analytics accounts and properties.""" - import asyncio - 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) + 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) + + 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]]: @@ -44,17 +51,22 @@ async def list_google_ads_links(property_id: int | str) -> List[Dict[str, Any]]: - A number - A string consisting of 'properties/' followed by a number """ - import asyncio - def _fetch(): - request = admin_v1beta.ListGoogleAdsLinksRequest( - parent=construct_property_rn(property_id) - ) - 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) + request = admin_v1beta.ListGoogleAdsLinksRequest( + parent=construct_property_rn(property_id) + ) + + 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 + ) + 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,16 +76,20 @@ async def get_property_details(property_id: int | str) -> Dict[str, Any]: - A number - A string consisting of 'properties/' followed by a number """ - import asyncio - def _fetch(): - client = create_admin_api_client_sync() - request = admin_v1beta.GetPropertyRequest( - name=construct_property_rn(property_id) - ) - response = client.get_property(request=request) - return proto_to_dict(response) - - return await asyncio.to_thread(_fetch) + 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) async def list_property_annotations( @@ -90,14 +106,19 @@ async def list_property_annotations( - A number - A string consisting of 'properties/' followed by a number """ - import asyncio - def _fetch(): - request = admin_v1alpha.ListReportingDataAnnotationsRequest( - parent=construct_property_rn(property_id) - ) - 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) + request = admin_v1alpha.ListReportingDataAnnotationsRequest( + parent=construct_property_rn(property_id) + ) + + 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 + ) + 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 416f30b..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 ( @@ -24,6 +26,7 @@ ) from analytics_mcp.tools.utils import ( construct_property_rn, + create_data_api_client, create_data_api_client_sync, proto_to_dict, ) @@ -137,40 +140,42 @@ async def run_report( report uses the property's default currency. return_property_quota: Whether to return property quota in the response. """ - import asyncio - def _fetch(): - request = data_v1beta.RunReportRequest( - property=construct_property_rn(property_id), - dimensions=[ - data_v1beta.Dimension(name=dimension) for dimension in dimensions - ], - metrics=[data_v1beta.Metric(name=metric) for metric in metrics], - date_ranges=[data_v1beta.DateRange(dr) for dr in date_ranges], - return_property_quota=return_property_quota, + request = data_v1beta.RunReportRequest( + property=construct_property_rn(property_id), + dimensions=[ + data_v1beta.Dimension(name=dimension) for dimension in dimensions + ], + metrics=[data_v1beta.Metric(name=metric) for metric in metrics], + date_ranges=[data_v1beta.DateRange(dr) for dr in date_ranges], + return_property_quota=return_property_quota, + ) + + if dimension_filter: + request.dimension_filter = data_v1beta.FilterExpression( + dimension_filter ) - if dimension_filter: - request.dimension_filter = data_v1beta.FilterExpression( - dimension_filter + if metric_filter: + request.metric_filter = data_v1beta.FilterExpression(metric_filter) + + if order_bys: + request.order_bys = [ + data_v1beta.OrderBy(order_by) for order_by in order_bys + ] + + if limit: + request.limit = limit + if offset: + request.offset = offset + if currency_code: + request.currency_code = currency_code + + if sys.platform == "win32": + def _fetch(): + return proto_to_dict( + create_data_api_client_sync().run_report(request) ) + return await asyncio.to_thread(_fetch) - if metric_filter: - request.metric_filter = data_v1beta.FilterExpression(metric_filter) - - if order_bys: - request.order_bys = [ - data_v1beta.OrderBy(order_by) for order_by in order_bys - ] - - if limit: - request.limit = limit - if offset: - request.offset = offset - if currency_code: - request.currency_code = currency_code - - response = create_data_api_client_sync().run_report(request) - - return proto_to_dict(response) - - 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 ec72a96..cc63503 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -14,10 +14,13 @@ """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,23 +322,41 @@ async def get_custom_dimensions_and_metrics( - A string consisting of 'properties/' followed by a number """ - import asyncio - 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) + 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" + ) + 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, + } diff --git a/analytics_mcp/tools/reporting/realtime.py b/analytics_mcp/tools/reporting/realtime.py index 99ac7fc..ae1f157 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -14,10 +14,13 @@ """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, ) @@ -130,36 +133,39 @@ async def run_realtime_report( https://developers.google.com/analytics/devguides/reporting/data/v1/basics#pagination. return_property_quota: Whether to return realtime property quota in the response. """ - import asyncio - def _fetch(): - request = data_v1beta.RunRealtimeReportRequest( - property=construct_property_rn(property_id), - dimensions=[ - data_v1beta.Dimension(name=dimension) for dimension in dimensions - ], - metrics=[data_v1beta.Metric(name=metric) for metric in metrics], - return_property_quota=return_property_quota, + request = data_v1beta.RunRealtimeReportRequest( + property=construct_property_rn(property_id), + dimensions=[ + data_v1beta.Dimension(name=dimension) for dimension in dimensions + ], + metrics=[data_v1beta.Metric(name=metric) for metric in metrics], + return_property_quota=return_property_quota, + ) + + if dimension_filter: + request.dimension_filter = data_v1beta.FilterExpression( + dimension_filter ) - if dimension_filter: - request.dimension_filter = data_v1beta.FilterExpression( - dimension_filter - ) + if metric_filter: + request.metric_filter = data_v1beta.FilterExpression(metric_filter) - if metric_filter: - request.metric_filter = data_v1beta.FilterExpression(metric_filter) + if order_bys: + request.order_bys = [ + data_v1beta.OrderBy(order_by) for order_by in order_bys + ] - if order_bys: - request.order_bys = [ - data_v1beta.OrderBy(order_by) for order_by in order_bys - ] + if limit: + request.limit = limit + if offset: + request.offset = offset - if limit: - request.limit = limit - 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 = create_data_api_client_sync().run_realtime_report(request) - return proto_to_dict(response) - - 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 b7784d1..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,46 +49,89 @@ def _get_package_version_with_fallback(): def _create_credentials() -> google.auth.credentials.Credentials: - """Returns Application Default Credentials with read-only scope.""" - import sys - import os - import google.oauth2.credentials - print("_create_credentials started", file=sys.stderr) - try: + """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): - print(f"Loading credentials directly from {path}", file=sys.stderr) - credentials = google.oauth2.credentials.Credentials.from_authorized_user_file(path, scopes=[_READ_ONLY_ANALYTICS_SCOPE]) - print("Credentials loaded successfully from file", file=sys.stderr) - return credentials - else: - print("GOOGLE_APPLICATION_CREDENTIALS not found, falling back to default", file=sys.stderr) - credentials, _ = google.auth.default(scopes=[_READ_ONLY_ANALYTICS_SCOPE]) - print("google.auth.default finished", file=sys.stderr) - return credentials - except Exception as e: - print(f"google.auth.default failed: {e}", file=sys.stderr) - raise + 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. + + Uses Application Default Credentials with read-only scope. + """ + return admin_v1beta.AnalyticsAdminServiceAsyncClient( + client_info=_CLIENT_INFO, credentials=_create_credentials() + ) + + +def create_data_api_client() -> data_v1beta.BetaAnalyticsDataAsyncClient: + """Returns a properly configured Google Analytics Data API async client. + + Uses Application Default Credentials with read-only scope. + """ + return data_v1beta.BetaAnalyticsDataAsyncClient( + client_info=_CLIENT_INFO, credentials=_create_credentials() + ) + + +def create_admin_alpha_api_client() -> ( + admin_v1alpha.AnalyticsAdminServiceAsyncClient +): + """Returns a properly configured Google Analytics Admin API (alpha) async client. + Uses Application Default Credentials with read-only scope. + """ + return admin_v1alpha.AnalyticsAdminServiceAsyncClient( + client_info=_CLIENT_INFO, credentials=_create_credentials() + ) + + def construct_property_rn(property_value: int | str) -> str: """Returns a property resource name in the format required by APIs.""" property_num = None