From 76fd6e9682de78ef0a526fc0e14a20c08bfc038c Mon Sep 17 00:00:00 2001 From: Deji Toye <75874198+dejitoye@users.noreply.github.com> Date: Thu, 28 May 2026 22:48:54 -0500 Subject: [PATCH 1/3] docs: add Ovative Cloud Run changes documentation Documents all modifications made to enable Cloud Run deployment: - FastMCP migration from google-adk - GoogleProvider OAuth setup - Per-user credential extraction in client.py - Dockerfile addition Author: Deji Toye --- CHANGES.md | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..5450d0d --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,115 @@ +# Ovative Changes — Google Analytics MCP + +> **Upstream repo:** https://github.com/googleanalytics/google-analytics-mcp +> **Last synced with upstream:** v0.6.0 (commit `1099661`) +> **Purpose of changes:** Enable deployment to Google Cloud Run with per-user OAuth authentication, removing the requirement for developers to install and configure the MCP locally. + +--- + +## What changed and why + +### 1. `pyproject.toml` +**What:** Replaced `google-adk` with `fastmcp`. +**Why:** The Google Agent Development Kit only supported stdio transport (local installs). FastMCP supports both stdio and HTTP transports, and provides a built-in Google OAuth proxy — required for Cloud Run deployment. + +```diff +- google-adk>=1.29.0 ++ fastmcp>=3.2.0 +``` + +--- + +### 2. `analytics_mcp/coordinator.py` +**What:** Complete rewrite. Replaced low-level `mcp.server.lowlevel.Server` + ADK `FunctionTool` with FastMCP. +**Why:** The original coordinator had no auth layer and no HTTP support. FastMCP's `GoogleProvider` handles the full OAuth proxy flow out of the box. + +Key additions: +- `GoogleProvider` OAuth — reads `ANALYTICS_MCP_OAUTH_CLIENT_ID`, `ANALYTICS_MCP_OAUTH_CLIENT_SECRET`, `ANALYTICS_MCP_BASE_URL` from environment +- When env vars are present → OAuth-protected HTTP server (Cloud Run mode) +- When env vars are absent → plain FastMCP instance (local stdio mode) +- Tool registration uses `mcp.tool(description=...)(fn)` pattern (compatible with FastMCP ≥ 3.3.x which removed the `description` argument from `add_tool()`) + +--- + +### 3. `analytics_mcp/server.py` +**What:** Replaced hardcoded `asyncio` + `mcp.server.stdio` runner with FastMCP's transport-aware `mcp.run()`. +**Why:** The original server always started in stdio mode. The new version checks for OAuth env vars and selects the transport automatically: +- `ANALYTICS_MCP_OAUTH_CLIENT_ID` + `ANALYTICS_MCP_OAUTH_CLIENT_SECRET` set → `streamable-http` on port 8080 (Cloud Run) +- Not set → `stdio` (local dev, unchanged behaviour) + +--- + +### 4. `analytics_mcp/tools/client.py` +**What:** Added per-request OAuth token extraction before the Application Default Credentials (ADC) fallback. +**Why:** Without this change, all Analytics API calls ran under the server's service account regardless of which user was signed in. With this change, each user's API calls use their own Google token — extracted from FastMCP's request context via `get_access_token()`. + +```python +# New — tries user token first (Cloud Run / OAuth mode) +from fastmcp.server.dependencies import get_access_token +token_obj = get_access_token() +if token_obj and token_obj.token: + return OAuthCredentials(token=token_obj.token) + +# Falls back to ADC (local dev mode — unchanged behaviour) +return google.auth.default(scopes=[...]) +``` + +--- + +### 5. `Dockerfile` _(new file — did not exist upstream)_ +**What:** Added a Dockerfile for building the Cloud Run container image. +**Why:** Google's repo had no Dockerfile. Required for `gcloud builds submit` and Cloud Run deployment. + +```dockerfile +FROM python:3.11-slim +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +WORKDIR /app +COPY . . +RUN uv pip install --system . +EXPOSE 8080 +CMD ["analytics-mcp"] +``` + +--- + +## Deployment + +### Environment variables (Cloud Run) +| Variable | Description | +|----------|-------------| +| `ANALYTICS_MCP_OAUTH_CLIENT_ID` | Google OAuth 2.0 client ID | +| `ANALYTICS_MCP_OAUTH_CLIENT_SECRET` | Google OAuth 2.0 client secret | +| `ANALYTICS_MCP_BASE_URL` | Public URL of the Cloud Run service | + +### Deploy command +```shell +gcloud builds submit \ + --tag us-central1-docker.pkg.dev/og-infosec-dev/mcp-servers/analytics-mcp:latest \ + --project=og-infosec-dev . + +gcloud run deploy analytics-mcp \ + --image us-central1-docker.pkg.dev/og-infosec-dev/mcp-servers/analytics-mcp:latest \ + --platform managed \ + --region us-central1 \ + --allow-unauthenticated \ + --min-instances=1 \ + --project=og-infosec-dev \ + --set-env-vars="ANALYTICS_MCP_OAUTH_CLIENT_ID=...,ANALYTICS_MCP_OAUTH_CLIENT_SECRET=...,ANALYTICS_MCP_BASE_URL=https://analytics-mcp-228754886076.us-central1.run.app" +``` + +> `--min-instances=1` is required. Scale-to-zero wipes FastMCP's OAuth client registry, breaking reconnections. + +### Local dev (no Cloud Run, no OAuth) +```shell +# Uses ADC — run this first if not already authenticated +gcloud auth application-default login + +pip install -e . +analytics-mcp +``` + +--- + +## Merging upstream Google changes + +See `UPSTREAM.md` for the full workflow. From fe19af52de1f9975b92476b904bb6a045955666a Mon Sep 17 00:00:00 2001 From: Deji Toye <75874198+dejitoye@users.noreply.github.com> Date: Thu, 28 May 2026 22:51:45 -0500 Subject: [PATCH 2/3] feat: add Cloud Run deployment support with FastMCP and Google OAuth - Replace google-adk with fastmcp in pyproject.toml - Rewrite coordinator.py to use FastMCP + GoogleProvider OAuth - Rewrite server.py to select stdio vs streamable-http transport based on env vars - Update tools/client.py to extract per-request OAuth token before falling back to ADC - Add Dockerfile for Cloud Run container builds Author: Deji Toye --- Dockerfile | 20 ++++ analytics_mcp/coordinator.py | 177 ++++++---------------------------- analytics_mcp/server.py | 51 +++------- analytics_mcp/tools/client.py | 15 +++ pyproject.toml | 2 +- 5 files changed, 79 insertions(+), 186 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e1d844f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Use a slim Python image +FROM python:3.11-slim + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# Set the working directory in the container +WORKDIR /app + +# Copy the project files into the container +COPY . . + +# Install the project and its dependencies +RUN uv pip install --system . + +# Expose port 8080 (default for Cloud Run) +EXPOSE 8080 + +# Define the command to run the server +CMD ["analytics-mcp"] diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index b53516d..39020d8 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -12,22 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Module declaring the singleton MCP server. +"""Module declaring the singleton MCP instance.""" -The singleton allows other modules to register their tools with the same MCP -server. -""" - -# 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 - -# ADK Tool Imports -from google.adk.tools.function_tool import FunctionTool -from google.adk.tools.mcp_tool.conversion_utils import adk_to_mcp_tool_type +import os +from fastmcp import FastMCP +from fastmcp.server.auth.providers.google import GoogleProvider from analytics_mcp.tools.admin.info import ( get_account_summaries, @@ -55,134 +44,32 @@ _run_conversions_report_description, ) -run_report_with_description = FunctionTool(run_report) -run_report_with_description.description = _run_report_description() -run_realtime_report_with_description = FunctionTool(run_realtime_report) -run_realtime_report_with_description.description = ( - _run_realtime_report_description() -) -run_funnel_report_with_description = FunctionTool(run_funnel_report) -run_funnel_report_with_description.description = ( - _run_funnel_report_description() -) -run_conversions_report_with_description = FunctionTool(run_conversions_report) -run_conversions_report_with_description.description = ( - _run_conversions_report_description() -) - -# Instantiate the ADK tools -tools = [ - FunctionTool(get_account_summaries), - FunctionTool(list_google_ads_links), - FunctionTool(get_property_details), - FunctionTool(list_property_annotations), - FunctionTool(get_custom_dimensions_and_metrics), - run_report_with_description, - run_realtime_report_with_description, - run_funnel_report_with_description, - run_conversions_report_with_description, -] - -tool_map = {t.name: t for t in tools} - -app = Server( - name="Google Analytics MCP Server", -) - -mcp_tools = [adk_to_mcp_tool_type(tool) for tool in tools] - - -def sanitize_mcp_schema_properties(node: dict) -> None: - """Ensure additionalProperties is a boolean value to satisfy certain MCP clients. - - This addresses issues with clients like Claude Desktop that fail when - additionalProperties is a schema object instead of a boolean. - """ - if not isinstance(node, dict): - return - - # Check and update the current node - if "additionalProperties" in node: - val = node["additionalProperties"] - if not isinstance(val, bool): - node["additionalProperties"] = True - - # Traverse children - for key, child in node.items(): - if isinstance(child, dict): - sanitize_mcp_schema_properties(child) - elif isinstance(child, list): - for element in child: - if isinstance(element, dict): - sanitize_mcp_schema_properties(element) - - -# Update the inputSchema for tools that do not have parameters. -# TODO: This is a bug in the ADK and can be removed once it is fixed. -# https://github.com/google/adk-python/issues/948 -for tool in mcp_tools: - # Check if inputSchema is empty - if tool.inputSchema == {}: - tool.inputSchema = {"type": "object", "properties": {}} - # Fix union type hints generating spurious "type": "null" - for prop in tool.inputSchema.get("properties", {}).values(): - if "anyOf" in prop and prop.get("type") == "null": - del prop["type"] - - # Ensure additionalProperties is compatible with all MCP clients - sanitize_mcp_schema_properties(tool.inputSchema) - - # Explicitly mark required fields for reporting tools to guide the LLM - if tool.name == "run_report": - tool.inputSchema["required"] = [ - "property_id", - "date_ranges", - "dimensions", - "metrics", - ] - elif tool.name == "run_realtime_report": - tool.inputSchema["required"] = ["property_id", "dimensions", "metrics"] - elif tool.name == "run_conversions_report": - tool.inputSchema["required"] = [ - "property_id", - "date_ranges", - "dimensions", - "metrics", - "conversion_spec", - ] - - -@app.list_tools() -async def list_tools() -> list[mcp_types.Tool]: - return mcp_tools - - -@app.call_tool() -async def call_mcp_tool(name: str, arguments: dict) -> list[mcp_types.Content]: - if name in tool_map: - tool = tool_map[name] - try: - adk_tool_response = await tool.run_async( - args=arguments, - tool_context=None, - ) - # Serialize the ADK tool response to JSON for MCP response - response_text = json.dumps(adk_tool_response, indent=2) - # MCP expects a list of mcp_types.Content parts - return [mcp_types.TextContent(type="text", text=response_text)] - - except Exception as e: - print( - f"MCP Server: Error executing ADK tool '{name}': {e}", - file=sys.stderr, - ) - # Return an error message in MCP format - error_text = json.dumps( - {"error": f"Failed to execute tool '{name}': {str(e)}"} - ) - return [mcp_types.TextContent(type="text", text=error_text)] - - error_text = json.dumps( - {"error": f"Tool '{name}' not implemented by this server."} +_CLIENT_ID = os.environ.get("ANALYTICS_MCP_OAUTH_CLIENT_ID") +_CLIENT_SECRET = os.environ.get("ANALYTICS_MCP_OAUTH_CLIENT_SECRET") +_BASE_URL = os.environ.get("ANALYTICS_MCP_BASE_URL", "http://localhost:8080") + +if _CLIENT_ID and _CLIENT_SECRET: + auth = GoogleProvider( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + base_url=_BASE_URL, + required_scopes=[ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/analytics.readonly", + ], ) - return [mcp_types.TextContent(type="text", text=error_text)] + mcp = FastMCP("Google Analytics MCP Server", auth=auth) +else: + mcp = FastMCP("Google Analytics MCP Server") + +mcp.add_tool(get_account_summaries) +mcp.add_tool(list_google_ads_links) +mcp.add_tool(get_property_details) +mcp.add_tool(list_property_annotations) +mcp.add_tool(get_custom_dimensions_and_metrics) +mcp.tool(description=_run_report_description())(run_report) +mcp.tool(description=_run_realtime_report_description())(run_realtime_report) +mcp.tool(description=_run_funnel_report_description())(run_funnel_report) +mcp.tool(description=_run_conversions_report_description())(run_conversions_report) diff --git a/analytics_mcp/server.py b/analytics_mcp/server.py index 99ec186..d206889 100755 --- a/analytics_mcp/server.py +++ b/analytics_mcp/server.py @@ -16,49 +16,20 @@ """Entry point for the Google Analytics MCP server.""" -import asyncio -import sys -import analytics_mcp.coordinator as coordinator -from mcp.server.lowlevel import NotificationOptions -from mcp.server.models import InitializationOptions -import mcp.server.stdio -import mcp.server -import traceback +import os +from analytics_mcp.coordinator import mcp -async def run_server_async(): - """Runs the MCP server over standard I/O.""" - print("Starting MCP Stdio Server:", coordinator.app.name, file=sys.stderr) - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await coordinator.app.run( - read_stream, - write_stream, - InitializationOptions( - server_name=coordinator.app.name, # Use the server name defined above - server_version="1.0.0", - capabilities=coordinator.app.get_capabilities( - # Define server capabilities - consult MCP docs for options - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) +def run_server() -> None: + _CLIENT_ID = os.environ.get("ANALYTICS_MCP_OAUTH_CLIENT_ID") + _CLIENT_SECRET = os.environ.get("ANALYTICS_MCP_OAUTH_CLIENT_SECRET") + port = int(os.environ.get("PORT", "8080")) - -def run_server(): - """Synchronous wrapper to run the async MCP server.""" - asyncio.run(run_server_async()) + if _CLIENT_ID and _CLIENT_SECRET: + mcp.run(transport="streamable-http", port=port, host="0.0.0.0") + else: + mcp.run() if __name__ == "__main__": - try: - run_server() - except KeyboardInterrupt: - print("\nMCP Server (stdio) stopped by user.", file=sys.stderr) - except Exception: - import traceback - - print("MCP Server (stdio) encountered an error:", file=sys.stderr) - traceback.print_exc() - finally: - print("MCP Server (stdio) process exiting.", file=sys.stderr) + run_server() diff --git a/analytics_mcp/tools/client.py b/analytics_mcp/tools/client.py index 93d2325..1b07d9d 100644 --- a/analytics_mcp/tools/client.py +++ b/analytics_mcp/tools/client.py @@ -78,6 +78,21 @@ def safe_popen(*args, **kwargs): def _get_credentials(): global _CREDENTIALS # Expected to be called under _client_lock + + # When running with OAuth (e.g. Cloud Run), use the token from the current + # request context set by FastMCP. These are per-request and must not be + # cached globally. + try: + from fastmcp.server.dependencies import get_access_token + from google.oauth2.credentials import Credentials as OAuthCredentials + + token_obj = get_access_token() + if token_obj and token_obj.token: + return OAuthCredentials(token=token_obj.token) + except Exception: + pass + + # Fall back to Application Default Credentials (cached for efficiency). if _CREDENTIALS is None: with prevent_stdio_inheritance(): _CREDENTIALS, _ = google.auth.default( diff --git a/pyproject.toml b/pyproject.toml index c7a2699..529c6a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "google-analytics-admin==0.29.0", "google-auth~=2.40", "mcp>=1.24.0", - "google-adk>=1.29.0", + "fastmcp>=3.2.0", "httpx>=0.28.1", ] keywords = ["google analytics", "analytics", "mcp", "ga4"] From 463b63e9c8b48b25eee942c9a1c5ec4395268ae6 Mon Sep 17 00:00:00 2001 From: Deji Toye <75874198+dejitoye@users.noreply.github.com> Date: Thu, 28 May 2026 22:52:23 -0500 Subject: [PATCH 3/3] docs: add Cloud Run deployment section to README Author: Deji Toye --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/README.md b/README.md index 1e43395..ecb1bec 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,58 @@ Credentials saved to file: [PATH_TO_CREDENTIALS_JSON] } ``` +## Deployment to Google Cloud Platform + +Instead of running this MCP server locally, you can host it on Google Cloud Run. This is useful for sharing the server across team members or running it as a web service. + +This mode uses OAuth for authentication. You will need a Google Cloud OAuth 2.0 Client ID and Secret. + +### Prerequisites + +1. A Google Cloud project. +2. The `gcloud` CLI installed, authenticated, and active project set. + ```shell + gcloud config set project YOUR_PROJECT_ID + ``` + +### Step 1: Build and Push Docker Image + +1. Create a repository in Artifact Registry: + ```shell + gcloud artifacts repositories create mcp-servers --repository-format=docker --location=us-central1 + ``` +2. Build and submit the image: + ```shell + gcloud builds submit --tag us-central1-docker.pkg.dev/YOUR_PROJECT_ID/mcp-servers/analytics-mcp:latest . + ``` + +### Step 2: Deploy to Google Cloud Run + +```shell +gcloud run deploy analytics-mcp \ + --image us-central1-docker.pkg.dev/YOUR_PROJECT_ID/mcp-servers/analytics-mcp:latest \ + --platform managed \ + --region us-central1 \ + --allow-unauthenticated \ + --set-env-vars="GOOGLE_PROJECT_ID=YOUR_PROJECT_ID,ANALYTICS_MCP_OAUTH_CLIENT_ID=YOUR_CLIENT_ID,ANALYTICS_MCP_OAUTH_CLIENT_SECRET=YOUR_CLIENT_SECRET,ANALYTICS_MCP_BASE_URL=YOUR_BASE_URL,FASTMCP_HOST=0.0.0.0" +``` + +Set `ANALYTICS_MCP_BASE_URL` to the Cloud Run URL assigned after your first deployment, then redeploy to update it. + +### Step 3: Configure MCP Client + +Update your MCP client configuration to point to the Cloud Run URL: + +```json +{ + "mcpServers": { + "analytics-mcp": { + "httpUrl": "https://your-cloud-run-url.a.run.app/mcp" + } + } +} +``` + ## Try it out 🥼 Launch Gemini Code Assist or Gemini CLI and type `/mcp`. You should see