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
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
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"]
132 changes: 131 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,20 @@
[![GitHub forks](https://img.shields.io/github/forks/googleanalytics/google-analytics-mcp?style=social)](https://github.com/googleanalytics/google-analytics-mcp/network/members)
[![YouTube Video Views](https://img.shields.io/youtube/views/PT4wGPxWiRQ)](https://www.youtube.com/watch?v=PT4wGPxWiRQ)

This repo contains the source code for running a local
This repo contains the source code for a
[MCP](https://modelcontextprotocol.io) server that interacts with APIs for
[Google Analytics](https://support.google.com/analytics).

The server supports two deployment modes:

- **Local (stdio)** — run as a subprocess by Gemini CLI, Claude Desktop, or
any MCP client. Uses Application Default Credentials. No extra infrastructure
needed.
- **Remote (HTTP + OAuth)** — deploy to
[Google Cloud Run](https://cloud.google.com/run) and connect from web-based
clients such as [claude.ai](https://claude.ai). Users authenticate via OAuth
2.0; no credentials need to be shared with the server operator.

Join the discussion and ask questions in the
[🤖-analytics-mcp channel](https://discord.com/channels/971845904002871346/1398002598665257060)
on Discord.
Expand Down Expand Up @@ -47,6 +57,15 @@ to provide several

## Setup instructions 🔧

Choose the mode that fits your use case:

- [Local setup (stdio)](#local-setup-stdio) — simplest, runs on your machine
- [Remote deployment (Cloud Run + OAuth)](#remote-deployment-cloud-run--oauth) — accessible from claude.ai and other web clients

---

### Local setup (stdio)

✨ Watch the [Google Analytics MCP Setup
Tutorial](https://youtu.be/nS8HLdwmVlY) on YouTube for a step-by-step
walkthrough of these instructions.
Expand Down Expand Up @@ -148,6 +167,117 @@ Credentials saved to file: [PATH_TO_CREDENTIALS_JSON]
}
```

---

### Remote deployment (Cloud Run + OAuth)

Deploy the server to Cloud Run so web-based MCP clients such as
[claude.ai](https://claude.ai) can connect to it. Each user authenticates with
their own Google account via OAuth 2.0 — no credentials need to be configured
on the server.

#### 1. Enable APIs ✅

[Enable](https://support.google.com/googleapi/answer/6158841) the same two APIs
as for local setup:

- [Google Analytics Admin API](https://console.cloud.google.com/apis/library/analyticsadmin.googleapis.com)
- [Google Analytics Data API](https://console.cloud.google.com/apis/library/analyticsdata.googleapis.com)

#### 2. Create an OAuth 2.0 client 🔑

1. Open [APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials)
in the Google Cloud Console.
1. Click **Create credentials → OAuth client ID**.
1. Choose **Web application** as the application type.
1. Under **Authorized redirect URIs**, add a placeholder for now — you will
update it after deploying:

```text
https://YOUR_SERVICE_URL/auth/callback
```

1. Click **Create** and note the **Client ID** and **Client secret**.

#### 3. Build and push the image with Cloud Build 🐳

[Cloud Build](https://cloud.google.com/build) builds and pushes the image
without requiring Docker locally. Substitute your Artifact Registry repository
path:

```shell
gcloud builds submit \
--tag REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO/google-analytics-mcp:latest .
```

#### 4. First deploy — get the service URL ☁️

Deploy without `ANALYTICS_MCP_BASE_URL` first so Cloud Run can assign the
service URL:

```shell
gcloud run deploy YOUR_SERVICE_NAME \
--image REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO/google-analytics-mcp:latest \
--platform managed \
--region YOUR_REGION \
--allow-unauthenticated \
--set-env-vars="GOOGLE_PROJECT_ID=YOUR_PROJECT_ID,\
ANALYTICS_MCP_OAUTH_CLIENT_ID=YOUR_OAUTH_CLIENT_ID,\
ANALYTICS_MCP_OAUTH_CLIENT_SECRET=YOUR_OAUTH_CLIENT_SECRET,\
FASTMCP_HOST=0.0.0.0"
```

Note the **Service URL** printed at the end of the output, e.g.
`https://YOUR_SERVICE_NAME-1234567890.REGION.run.app`.

#### 5. Update OAuth redirect URI and redeploy ☁️

1. Return to your OAuth client in the
[Google Cloud Console](https://console.cloud.google.com/apis/credentials)
and replace the placeholder redirect URI with:

```text
https://YOUR_SERVICE_URL/auth/callback
```

1. Redeploy with `ANALYTICS_MCP_BASE_URL` set to the service URL:

```shell
gcloud run deploy YOUR_SERVICE_NAME \
--image REGION-docker.pkg.dev/YOUR_PROJECT_ID/YOUR_REPO/google-analytics-mcp:latest \
--platform managed \
--region YOUR_REGION \
--allow-unauthenticated \
--set-env-vars="GOOGLE_PROJECT_ID=YOUR_PROJECT_ID,\
ANALYTICS_MCP_OAUTH_CLIENT_ID=YOUR_OAUTH_CLIENT_ID,\
ANALYTICS_MCP_OAUTH_CLIENT_SECRET=YOUR_OAUTH_CLIENT_SECRET,\
ANALYTICS_MCP_BASE_URL=https://YOUR_SERVICE_URL,\
FASTMCP_HOST=0.0.0.0"
```

#### 6. Connect from claude.ai 🤖

1. Open [claude.ai](https://claude.ai) and go to **Settings → Integrations**.
1. Add a new integration with the URL:

```text
https://YOUR_SERVICE_URL/mcp
```

1. Authorize with your Google account when prompted.

#### Environment variable reference

| Variable | Required | Description |
| --- | --- | --- |
| `ANALYTICS_MCP_OAUTH_CLIENT_ID` | Yes (HTTP mode) | OAuth 2.0 client ID |
| `ANALYTICS_MCP_OAUTH_CLIENT_SECRET` | Yes (HTTP mode) | OAuth 2.0 client secret |
| `ANALYTICS_MCP_BASE_URL` | Yes (HTTP mode) | Public URL of the deployed service |
| `FASTMCP_HOST` | Cloud Run | Set to `0.0.0.0` to accept external connections |
| `PORT` | Cloud Run | Port to listen on (default: `8080`, auto-set by Cloud Run) |

---

## Try it out 🥼

Launch Gemini Code Assist or Gemini CLI and type `/mcp`. You should see
Expand Down
162 changes: 38 additions & 124 deletions analytics_mcp/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,11 @@
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
import os

# 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
from fastmcp import FastMCP
from fastmcp.server.auth.providers.google import GoogleProvider
from fastmcp.tools.base import Tool

from analytics_mcp.tools.admin.info import (
get_account_summaries,
Expand All @@ -51,121 +46,40 @@
_run_funnel_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()
_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/analytics.readonly",
],
)
mcp = FastMCP("Google Analytics MCP Server", auth=_auth)
else:
mcp = FastMCP("Google Analytics MCP Server")

mcp.add_tool(Tool.from_function(get_account_summaries))
mcp.add_tool(Tool.from_function(list_google_ads_links))
mcp.add_tool(Tool.from_function(get_property_details))
mcp.add_tool(Tool.from_function(list_property_annotations))
mcp.add_tool(Tool.from_function(get_custom_dimensions_and_metrics))
mcp.add_tool(
Tool.from_function(run_report, description=_run_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,
]

tool_map = {t.name: t for t in tools}

app = Server(
name="Google Analytics MCP Server",
mcp.add_tool(
Tool.from_function(
run_realtime_report, description=_run_realtime_report_description()
)
)

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"]


@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."}
mcp.add_tool(
Tool.from_function(
run_funnel_report, description=_run_funnel_report_description()
)
return [mcp_types.TextContent(type="text", text=error_text)]
)
Loading