diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..648a39d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 1e43395..b017066 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index f1681ac..278f2aa 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -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, @@ -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)] +) diff --git a/analytics_mcp/server.py b/analytics_mcp/server.py index 99ec186..3026731 100755 --- a/analytics_mcp/server.py +++ b/analytics_mcp/server.py @@ -16,49 +16,44 @@ """Entry point for the Google Analytics MCP server.""" -import asyncio +import os 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 analytics_mcp.coordinator as coordinator -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: + """Runs the MCP server. -def run_server(): - """Synchronous wrapper to run the async MCP server.""" - asyncio.run(run_server_async()) + Uses streamable-http transport with OAuth when ANALYTICS_MCP_OAUTH_CLIENT_ID + and ANALYTICS_MCP_OAUTH_CLIENT_SECRET are set (Cloud Run / remote mode). + Falls back to stdio transport for local subprocess use. + """ + _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")) + + if _client_id and _client_secret: + print( + f"Starting MCP HTTP Server on port {port} (OAuth mode)", + file=sys.stderr, + ) + coordinator.mcp.run( + transport="streamable-http", port=port, host="0.0.0.0" + ) + else: + print("Starting MCP Stdio Server", file=sys.stderr) + coordinator.mcp.run() if __name__ == "__main__": try: run_server() except KeyboardInterrupt: - print("\nMCP Server (stdio) stopped by user.", file=sys.stderr) + print("\nMCP Server stopped by user.", file=sys.stderr) except Exception: - import traceback - - print("MCP Server (stdio) encountered an error:", file=sys.stderr) + print("MCP Server encountered an error:", file=sys.stderr) traceback.print_exc() finally: - print("MCP Server (stdio) process exiting.", file=sys.stderr) + print("MCP Server process exiting.", file=sys.stderr) diff --git a/analytics_mcp/tools/client.py b/analytics_mcp/tools/client.py index f5ec0c4..870941f 100644 --- a/analytics_mcp/tools/client.py +++ b/analytics_mcp/tools/client.py @@ -53,8 +53,20 @@ def _get_package_version_with_fallback(): def _get_credentials(): + # In HTTP/OAuth mode, retrieve the per-request token from FastMCP context. + # This must be checked first so each user's request uses their own token. + try: + from fastmcp.server.dependencies import get_access_token + from google.oauth2.credentials import Credentials as OAuthCredentials + + token = get_access_token() + if token and token.token: + return OAuthCredentials(token=token.token) + except Exception: + pass + + # Fall back to Application Default Credentials for stdio/local mode. global _CREDENTIALS - # Expected to be called under _client_lock if _CREDENTIALS is None: _CREDENTIALS, _ = google.auth.default( scopes=[_READ_ONLY_ANALYTICS_SCOPE] diff --git a/pyproject.toml b/pyproject.toml index ff6ddcc..db9c43f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "mcp>=1.24.0", "google-adk>=1.29.0", "httpx>=0.28.1", + "fastmcp>=3.2.0", ] keywords = ["google analytics", "analytics", "mcp", "ga4"] classifiers = [