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
19 changes: 19 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.git
.github
__pycache__
*.pyc
*.pyo
.nox
.pytest_cache
tests/
noxfile.py
CONTRIBUTING.md
LICENSE
README.md
CODEOWNERS
skills-lock.json
secrets/
.env
*.egg-info
dist/
build/
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# --- GCP Credentials ---
# Paste the full service account JSON here (single line or multiline)
GOOGLE_SA_KEY_JSON={"type":"service_account","project_id":"...","private_key":"..."}
GOOGLE_CLOUD_PROJECT=your-gcp-project-id

# --- OAuth (required for streamable-http / remote access) ---
ANALYTICS_MCP_OAUTH_CLIENT_ID=your-oauth-client-id.apps.googleusercontent.com
ANALYTICS_MCP_OAUTH_CLIENT_SECRET=your-oauth-client-secret
ANALYTICS_MCP_BASE_URL=http://localhost:8080

# --- Server ---
PORT=8080
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,8 @@ system_tests/local_test_setup
pylintrc
pylintrc.test

.agents/skills
.agents/skills

# Secrets
.env
secrets/
47 changes: 47 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Multi-stage build for Google Analytics MCP Server
# Security: non-root, minimal image, read-only filesystem

# ============================================================
# Stage 1: Build dependencies
# ============================================================
FROM python:3.12-slim-bookworm AS builder

WORKDIR /build

COPY pyproject.toml ./
COPY analytics_mcp/ ./analytics_mcp/

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir ".[deploy]"

# ============================================================
# Stage 2: Production image
# ============================================================
FROM python:3.12-slim-bookworm AS production

# Security: non-root user with no login shell
RUN groupadd --gid 1001 mcpuser && \
useradd --uid 1001 --gid 1001 --shell /usr/sbin/nologin --create-home mcpuser

COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /app
COPY analytics_mcp/ ./analytics_mcp/

# Application code is immutable
RUN chmod -R 555 /app

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:${PORT:-8080}/mcp')" || exit 1

USER mcpuser

EXPOSE 8080

CMD ["python", "-m", "analytics_mcp.server_deploy"]
119 changes: 119 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
VERSION ?= $(shell python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
PLATFORM ?= linux/amd64
IMAGE ?= analytics-mcp
PORT ?= 8080

# ==============================================================================
# Local Development
# ==============================================================================

.PHONY: install
install:
pip install --editable ".[dev,deploy]"

.PHONY: lint
lint:
black --check analytics_mcp/ tests/

.PHONY: format
format:
black analytics_mcp/ tests/

.PHONY: test
test:
nox -s test

.PHONY: clean
clean:
-rm -rf build/ dist/ *.egg-info .nox __pycache__
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true

# ==============================================================================
# Docker
# ==============================================================================

.PHONY: package
package:
docker build \
--platform=$(PLATFORM) \
--tag $(IMAGE):$(VERSION) \
--tag $(IMAGE):latest \
.

.PHONY: docker-run
docker-run:
docker run --rm \
--platform=$(PLATFORM) \
--env-file .env \
-v $${GOOGLE_APPLICATION_CREDENTIALS_HOST_PATH:-./secrets/sa-key.json}:/secrets/sa-key.json:ro \
-p $(PORT):8080 \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=64m \
--security-opt no-new-privileges:true \
$(IMAGE):$(VERSION)

.PHONY: docker-up
docker-up:
docker compose up --build -d

.PHONY: docker-down
docker-down:
docker compose down

.PHONY: docker-logs
docker-logs:
docker compose logs -f analytics-mcp

# ==============================================================================
# CI — Run in Docker (no local Python needed)
# ==============================================================================

.PHONY: lint-in-docker
lint-in-docker:
$(MAKE) COMMAND="black --check analytics_mcp/ tests/" _run_in_docker

.PHONY: test-in-docker
test-in-docker:
$(MAKE) COMMAND="nox -s test" _run_in_docker

.PHONY: _run_in_docker
_run_in_docker:
docker run --rm \
--platform=$(PLATFORM) \
--volume $(CURDIR):/workspace \
--workdir /workspace \
$(EXTRA_DOCKER_ARGS) \
python:3.12-slim-bookworm \
bash -c "pip install --quiet '.[dev,deploy]' && $(COMMAND)"

# ==============================================================================
# Helpers
# ==============================================================================

.PHONY: env
env:
@test -f .env || cp .env.example .env
@echo ".env file ready — edit with your credentials"

.PHONY: help
help:
@echo "Local Development:"
@echo " make install Install editable with dev + deploy deps"
@echo " make lint Check formatting with black"
@echo " make format Auto-format with black"
@echo " make test Run tests via nox"
@echo " make clean Remove build artifacts"
@echo ""
@echo "Docker:"
@echo " make package Build production Docker image"
@echo " make docker-run Run container standalone"
@echo " make docker-up Start via docker compose"
@echo " make docker-down Stop docker compose"
@echo " make docker-logs Tail compose logs"
@echo ""
@echo "CI (in Docker):"
@echo " make lint-in-docker Lint without local Python"
@echo " make test-in-docker Test without local Python"
@echo ""
@echo "Helpers:"
@echo " make env Create .env from .env.example"
2 changes: 1 addition & 1 deletion analytics_mcp/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ async def call_mcp_tool(name: str, arguments: dict) -> list[mcp_types.Content]:
)
# Return an error message in MCP format
error_text = json.dumps(
{"error": f"Failed to execute tool '{name}': {str(e)}"}
{"error": f"Failed to execute tool '{name}'. Check server logs for details."}
)
return [mcp_types.TextContent(type="text", text=error_text)]

Expand Down
Loading