From 56b198577ba415f123e2d4323ae3d2dab5f1cffd Mon Sep 17 00:00:00 2001 From: Alain Sanchez Date: Sun, 23 Aug 2026 10:50:34 +0300 Subject: [PATCH 1/2] feat: add Podman Streamable HTTP interoperability --- .dockerignore | 13 ++ Dockerfile | 21 ++ README.md | 4 + analytics_mcp/coordinator.py | 207 +++++------------- analytics_mcp/server.py | 80 ++++--- .../google-analytics-mcp.container.example | 22 ++ docs/podman-http-interoperability.md | 140 ++++++++++++ pyproject.toml | 5 +- tests/coordinator_test.py | 52 +++++ tests/server_test.py | 68 ++++++ 10 files changed, 428 insertions(+), 184 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 deploy/podman/google-analytics-mcp.container.example create mode 100644 docs/podman-http-interoperability.md create mode 100644 tests/coordinator_test.py create mode 100644 tests/server_test.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..0e28eaa1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.agents +.codex +.nox +.venv +__pycache__ +*.py[cod] +*.egg-info +build +dist +docs +tests diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..07a903b7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index eeb81a5d..8534c530 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index b53516d2..3ef8fcaf 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -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) diff --git a/analytics_mcp/server.py b/analytics_mcp/server.py index 99ec186e..84e98a1c 100755 --- a/analytics_mcp/server.py +++ b/analytics_mcp/server.py @@ -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 diff --git a/deploy/podman/google-analytics-mcp.container.example b/deploy/podman/google-analytics-mcp.container.example new file mode 100644 index 00000000..51145826 --- /dev/null +++ b/deploy/podman/google-analytics-mcp.container.example @@ -0,0 +1,22 @@ +[Unit] +Description=Google Analytics MCP over Streamable HTTP +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName=google-analytics-mcp +Image=localhost/google-analytics-mcp:local +PublishPort=127.0.0.1:8081:8080 +Volume=/mnt/c/Users/YOUR_WINDOWS_USER/.config/google-analytics/credentials.json:/run/secrets/google-analytics/credentials.json:ro +Environment=GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/google-analytics/credentials.json +ReadOnly=true +NoNewPrivileges=true +DropCapability=all +Tmpfs=/tmp:rw,nosuid,nodev,noexec,size=64m + +[Service] +Restart=on-failure +TimeoutStopSec=15 + +[Install] +WantedBy=default.target diff --git a/docs/podman-http-interoperability.md b/docs/podman-http-interoperability.md new file mode 100644 index 00000000..29b16d5f --- /dev/null +++ b/docs/podman-http-interoperability.md @@ -0,0 +1,140 @@ +# Podman and Streamable HTTP interoperability + +This deployment keeps Python, the MCP runtime, and all dependencies inside a +rootless Podman container in Ubuntu on WSL. MCP clients connect to one local, +vendor-neutral Streamable HTTP endpoint: + +```text +http://127.0.0.1:8081/mcp +``` + +The host port is bound only to loopback. The server has no MCP-layer +authentication, so do not publish it on `0.0.0.0` or expose it to a LAN. + +## 1. Create credentials in Google Cloud Console + +1. Open [Google Cloud Console](https://console.cloud.google.com/) and select or + create the project that will own the service account. +2. Open **APIs & Services > Library** and enable: + - **Google Analytics Data API** + - **Google Analytics Admin API** +3. Open **IAM & Admin > Service Accounts** and choose **Create service + account**. +4. Give it a descriptive name such as `google-analytics-mcp`. Do not grant a + Google Cloud project role: access to Analytics data is granted separately + in GA4. +5. Open the new service account, select **Keys > Add key > Create new key**, + choose **JSON**, and download the file. +6. Keep the JSON outside this repository. Never commit it, copy it into the + image, paste it into an MCP client configuration, or share its contents. +7. In Google Analytics, open **Admin > Property access management** for the + required GA4 property, add the service-account email, and grant **Viewer**. + Use account-level access only when the server must see every property in the + Analytics account. +8. If revenue or cost metrics are needed, make sure the GA4 role does not + restrict access to those metrics. + +If organization policy prevents service-account key creation, ask the Google +Cloud administrator for an approved workload-identity alternative. Do not +bypass the policy. + +## 2. Build the image in Ubuntu on WSL + +Run from the repository directory mounted in WSL: + +```bash +podman build \ + --pull=missing \ + --tag localhost/google-analytics-mcp:local \ + --file Dockerfile \ + . +``` + +No Python package or build tool is installed in Windows or WSL. + +## 3. Start the hardened local container + +Translate the Windows JSON path to its WSL form. For example, +`C:\Users\YOUR_WINDOWS_USER\.config\google-analytics\credentials.json` becomes +`/mnt/c/Users/YOUR_WINDOWS_USER/.config/google-analytics/credentials.json`. + +```bash +podman run --detach \ + --name google-analytics-mcp \ + --replace \ + --restart unless-stopped \ + --read-only \ + --security-opt no-new-privileges \ + --cap-drop all \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \ + --publish 127.0.0.1:8081:8080 \ + --volume /ABSOLUTE/WSL/PATH/credentials.json:/run/secrets/google-analytics/credentials.json:ro \ + --env GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/google-analytics/credentials.json \ + localhost/google-analytics-mcp:local +``` + +The container image selects stateful Streamable HTTP. Running +`analytics-mcp` outside that image still defaults to stdio for backward +compatibility. + +For a persistent user service, copy +`deploy/podman/google-analytics-mcp.container.example` to +`~/.config/containers/systemd/google-analytics-mcp.container`, replace its +credential path, then reload and start the generated user service: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now google-analytics-mcp.service +``` + +The Quadlet file is configuration for Podman; it does not install software. + +## 4. Configure MCP clients + +Credentials remain solely in the server container. Every client receives only +the endpoint URL. + +Codex `config.toml`: + +```toml +[mcp_servers.google_analytics] +url = "http://127.0.0.1:8081/mcp" +``` + +Antigravity and Antigravity IDE MCP configuration: + +```json +{ + "mcpServers": { + "google_analytics": { + "serverUrl": "http://127.0.0.1:8081/mcp" + } + } +} +``` + +Other MCP clients should select **Streamable HTTP** and use the same URL. Do +not configure SSE and do not append a session ID. + +## 5. Verify + +From any MCP client: + +1. Confirm that `google_analytics` connects. +2. List tools and confirm that nine tools are available. +3. Call `get_property_details` with the authorized numeric property ID. +4. Start a second client at the same time and repeat the tool listing. Each + client must maintain an independent MCP session. + +To inspect service status without exposing credentials: + +```bash +podman ps --filter name=google-analytics-mcp +podman logs --tail 100 google-analytics-mcp +``` + +Stop the manually started container with: + +```bash +podman stop --time 15 google-analytics-mcp +``` diff --git a/pyproject.toml b/pyproject.toml index 30187164..13ce2167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,12 +8,11 @@ requires-python = ">=3.10" license = "Apache-2.0" readme = "README.md" dependencies = [ + "fastmcp==4.0.0b3", "google-analytics-data==0.23.0", "google-analytics-admin==0.30.1", "google-auth~=2.40", - "mcp>=1.24.0,<2", - "google-adk>=1.29.0", - "httpx>=0.28.1", + "mcp==2.0.0", ] keywords = ["google analytics", "analytics", "mcp", "ga4"] classifiers = [ diff --git a/tests/coordinator_test.py b/tests/coordinator_test.py new file mode 100644 index 00000000..7536aeba --- /dev/null +++ b/tests/coordinator_test.py @@ -0,0 +1,52 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for the FastMCP coordinator.""" + +import asyncio +import unittest + +from analytics_mcp import coordinator + + +class TestCoordinator(unittest.TestCase): + """Test the registered MCP tools and compatibility handler.""" + + def test_registers_all_google_analytics_tools(self): + """All public tools should be visible through FastMCP.""" + tools = asyncio.run(coordinator.mcp.list_tools()) + self.assertEqual( + {tool.name for tool in tools}, + { + "get_account_summaries", + "get_custom_dimensions_and_metrics", + "get_property_details", + "list_google_ads_links", + "list_property_annotations", + "run_conversions_report", + "run_funnel_report", + "run_realtime_report", + "run_report", + }, + ) + + def test_subscriptions_listen_handler_is_idempotent(self): + """The compatibility handler must not be installed twice.""" + self.assertIn( + "subscriptions/listen", + coordinator.mcp._mcp_server._request_handlers, + ) + self.assertFalse( + coordinator.ensure_subscriptions_listen(coordinator.mcp) + ) diff --git a/tests/server_test.py b/tests/server_test.py new file mode 100644 index 00000000..6cc6d9e4 --- /dev/null +++ b/tests/server_test.py @@ -0,0 +1,68 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for transport configuration.""" + +import os +import unittest +from unittest import mock + +from analytics_mcp import server + + +class TestServer(unittest.TestCase): + """Test the stdio and Streamable HTTP entry points.""" + + @mock.patch.dict(os.environ, {}, clear=True) + @mock.patch.object(server.mcp, "run") + def test_stdio_is_the_default(self, run): + """Existing stdio clients should retain the default behavior.""" + server.run_server() + run.assert_called_once_with() + + @mock.patch.dict( + os.environ, + { + "ANALYTICS_MCP_TRANSPORT": "streamable-http", + "HOST": "0.0.0.0", + "PORT": "9090", + }, + clear=True, + ) + @mock.patch.object(server.mcp, "run") + def test_streamable_http_configuration(self, run): + """HTTP configuration should map to the stateful FastMCP transport.""" + server.run_server() + run.assert_called_once_with( + transport="streamable-http", + host="0.0.0.0", + port=9090, + uvicorn_config={"access_log": False}, + ) + + @mock.patch.dict( + os.environ, + {"ANALYTICS_MCP_TRANSPORT": "invalid"}, + clear=True, + ) + def test_rejects_unknown_transport(self): + """Mistyped transports should fail instead of silently using stdio.""" + with self.assertRaisesRegex(ValueError, "Unsupported"): + server.run_server() + + @mock.patch.dict(os.environ, {"PORT": "70000"}, clear=True) + def test_rejects_invalid_port(self): + """HTTP ports must be valid TCP ports.""" + with self.assertRaisesRegex(ValueError, "between 1 and 65535"): + server._configured_port() From 1d39a5b279f71ccdc60d9bc1ae40ef794b34d7f2 Mon Sep 17 00:00:00 2001 From: Alain Sanchez Date: Sun, 23 Aug 2026 12:24:34 +0300 Subject: [PATCH 2/2] fix: restrict setuptools package discovery --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 13ce2167..82839231 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,3 +50,6 @@ dev = ["black", "nox >=2026.2.9, <2027"] [build-system] requires = ["setuptools>=82.0.0", "wheel"] build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["analytics_mcp*"]