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
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.git
.github
.agents
.codex
.nox
.venv
__pycache__
*.py[cod]
*.egg-info
build
dist
docs
tests
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM docker.io/library/python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
ANALYTICS_MCP_TRANSPORT=streamable-http \
HOST=0.0.0.0 \
PORT=8080 \
HOME=/tmp

WORKDIR /app

COPY pyproject.toml README.md LICENSE ./
COPY analytics_mcp ./analytics_mcp

RUN python -m pip install --no-cache-dir .

USER 65532:65532

EXPOSE 8080

CMD ["analytics-mcp"]
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ to provide several

## Setup instructions 🔧

For a zero-install Windows/WSL deployment using rootless Podman and a shared
Streamable HTTP endpoint for Codex, Antigravity, and other MCP clients, see
[Podman and Streamable HTTP interoperability](docs/podman-http-interoperability.md).

✨ Watch the [Google Analytics MCP Setup
Tutorial](https://youtu.be/nS8HLdwmVlY) on YouTube for a step-by-step
walkthrough of these instructions.
Expand Down
207 changes: 57 additions & 150 deletions analytics_mcp/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,177 +12,84 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Module declaring the singleton MCP server.
"""Declare and configure the singleton Google Analytics MCP server."""

The singleton allows other modules to register their tools with the same MCP
server.
"""
from collections.abc import Callable
from typing import Any

# 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
from fastmcp import FastMCP
from fastmcp.tools import Tool
from mcp import types as mcp_types
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler

from analytics_mcp.tools.admin.info import (
get_account_summaries,
list_google_ads_links,
get_property_details,
list_google_ads_links,
list_property_annotations,
)
from analytics_mcp.tools.reporting.conversions import (
_run_conversions_report_description,
run_conversions_report,
)
from analytics_mcp.tools.reporting.core import (
run_report,
_run_report_description,
run_report,
)
from analytics_mcp.tools.reporting.realtime import (
run_realtime_report,
_run_realtime_report_description,
from analytics_mcp.tools.reporting.funnel import (
_run_funnel_report_description,
run_funnel_report,
)
from analytics_mcp.tools.reporting.metadata import (
get_custom_dimensions_and_metrics,
)
from analytics_mcp.tools.reporting.funnel import (
run_funnel_report,
_run_funnel_report_description,
)
from analytics_mcp.tools.reporting.conversions import (
run_conversions_report,
_run_conversions_report_description,
from analytics_mcp.tools.reporting.realtime import (
_run_realtime_report_description,
run_realtime_report,
)

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()
)
mcp = FastMCP("Google Analytics MCP Server")

# 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 _register_tool(
function: Callable[..., Any], description: str | None = None
) -> None:
"""Register an existing async function as a FastMCP tool."""
mcp.add_tool(Tool.from_function(fn=function, description=description))


_register_tool(get_account_summaries)
_register_tool(list_google_ads_links)
_register_tool(get_property_details)
_register_tool(list_property_annotations)
_register_tool(get_custom_dimensions_and_metrics)
_register_tool(run_report, _run_report_description())
_register_tool(run_realtime_report, _run_realtime_report_description())
_register_tool(run_funnel_report, _run_funnel_report_description())
_register_tool(run_conversions_report, _run_conversions_report_description())

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.
def ensure_subscriptions_listen(server: FastMCP) -> bool:
"""Install the optional MCP 2 subscription handler when absent.

Some clients probe ``subscriptions/listen`` during connection setup. The
handler is added idempotently so the same stateful Streamable HTTP endpoint
works with those clients without affecting clients that do not use it.

Returns:
True when the handler was installed, otherwise False.
"""
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."}
low_level_server = server._mcp_server
if "subscriptions/listen" in low_level_server._request_handlers:
return False

subscription_bus = InMemorySubscriptionBus()
low_level_server.add_request_handler(
"subscriptions/listen",
mcp_types.SubscriptionsListenRequestParams,
ListenHandler(subscription_bus),
)
return [mcp_types.TextContent(type="text", text=error_text)]
return True


ensure_subscriptions_listen(mcp)
80 changes: 49 additions & 31 deletions analytics_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,49 +16,67 @@

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

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={},
),
),
STDIO_TRANSPORT = "stdio"
HTTP_TRANSPORTS = {"http", "streamable-http"}
SUPPORTED_TRANSPORTS = {STDIO_TRANSPORT, *HTTP_TRANSPORTS}


def _configured_transport() -> str:
"""Return and validate the requested transport."""
transport = os.getenv("ANALYTICS_MCP_TRANSPORT", STDIO_TRANSPORT)
transport = transport.strip().lower()
if transport not in SUPPORTED_TRANSPORTS:
choices = ", ".join(sorted(SUPPORTED_TRANSPORTS))
raise ValueError(
"Unsupported ANALYTICS_MCP_TRANSPORT "
f"{transport!r}; expected one of: {choices}"
)
return transport


def _configured_port() -> int:
"""Return and validate the HTTP listen port."""
raw_port = os.getenv("PORT", "8080")
try:
port = int(raw_port)
except ValueError as error:
raise ValueError(
f"PORT must be an integer, got {raw_port!r}"
) from error
if not 1 <= port <= 65535:
raise ValueError(f"PORT must be between 1 and 65535, got {port}")
return port


def run_server() -> None:
"""Run stdio by default, or stateful Streamable HTTP when configured."""
transport = _configured_transport()
if transport == STDIO_TRANSPORT:
mcp.run()
return

def run_server():
"""Synchronous wrapper to run the async MCP server."""
asyncio.run(run_server_async())
mcp.run(
transport="streamable-http",
host=os.getenv("HOST", "127.0.0.1"),
port=_configured_port(),
uvicorn_config={"access_log": False},
)


if __name__ == "__main__":
try:
run_server()
except KeyboardInterrupt:
print("\nMCP Server (stdio) stopped by user.", file=sys.stderr)
print("\nGoogle Analytics MCP server stopped.", file=sys.stderr)
except Exception:
import traceback

print("MCP Server (stdio) encountered an error:", file=sys.stderr)
print(
"Google Analytics MCP server encountered an error:", file=sys.stderr
)
traceback.print_exc()
finally:
print("MCP Server (stdio) process exiting.", file=sys.stderr)
raise
Loading