Skip to content
Closed
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
115 changes: 115 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
177 changes: 32 additions & 145 deletions analytics_mcp/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Loading