From 88c16c840b4ef7f0a469a2e0684f42caa6e103d3 Mon Sep 17 00:00:00 2001 From: lmiranda Date: Tue, 3 Feb 2026 21:11:29 -0500 Subject: [PATCH] feat: Add Docker infrastructure with Caddy and startup scripts (#25, #26) Issue #25 - Docker multi-service infrastructure: - Create docker/Dockerfile with multi-stage build, git support, port 8080 - Create docker/docker-compose.yml with app + Caddy services - Create docker/Caddyfile for HTTPS termination and reverse proxy - Create docker/.env.example with configuration template Issue #26 - Startup scripts and tests: - Create scripts/start.sh for production startup with env validation - Create scripts/healthcheck.sh for Docker health checks - Add health endpoint tests to test_mcp_endpoints.py - Fix middleware order (HealthCheckBypass must wrap BearerAuth) - Fix pyproject.toml testpaths to use 'tests' directory - Update test_config.py for new defaults (0.0.0.0:8080) Co-Authored-By: Claude Opus 4.5 --- docker/.env.example | 38 +++++++++++++++++ docker/Caddyfile | 46 ++++++++++++++++++++ docker/Dockerfile | 65 ++++++++++++++++++++++++++++ docker/docker-compose.yml | 66 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- scripts/healthcheck.sh | 21 +++++++++ scripts/start.sh | 60 ++++++++++++++++++++++++++ src/gitea_mcp_remote/server_http.py | 7 +-- tests/test_config.py | 24 ++++++----- tests/test_mcp_endpoints.py | 31 +++++++++++++- 10 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 docker/.env.example create mode 100644 docker/Caddyfile create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100755 scripts/healthcheck.sh create mode 100755 scripts/start.sh diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..cde001e --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,38 @@ +# Gitea MCP Remote - Docker Environment Configuration +# +# Copy this file to .env and fill in your values: +# cp .env.example .env + +# ============================================================================= +# Required Configuration +# ============================================================================= + +# Gitea instance URL (e.g., https://gitea.example.com) +GITEA_URL=https://gitea.example.com + +# Gitea API token for authentication +# Generate at: Settings -> Applications -> Generate New Token +GITEA_TOKEN=your_gitea_api_token_here + +# Default repository owner/organization +GITEA_OWNER=your_username_or_org + +# ============================================================================= +# Optional Configuration +# ============================================================================= + +# Default repository name (optional - can be specified per-request) +GITEA_REPO= + +# Bearer token for MCP endpoint authentication (optional) +# If set, clients must include "Authorization: Bearer " header +AUTH_TOKEN= + +# MCP authentication mode: 'required', 'optional', or 'none' +MCP_AUTH_MODE=optional + +# Tool filtering (optional, comma-separated) +# ENABLED_TOOLS=list_issues,create_issue,list_labels +# DISABLED_TOOLS=delete_issue,delete_label +ENABLED_TOOLS= +DISABLED_TOOLS= diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..0682345 --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,46 @@ +# Caddy reverse proxy configuration for Gitea MCP Remote +# +# This configuration provides: +# - HTTPS termination with automatic certificates +# - Reverse proxy to the Python MCP server +# - Health check endpoint passthrough +# - MCP protocol endpoint routing + +{ + # Global options + email admin@example.com + + # For local development, disable HTTPS redirect + # auto_https off +} + +# Default site - adjust domain as needed +:443, :80 { + # Health check endpoint - no authentication + handle /health { + reverse_proxy app:8080 + } + + # MCP protocol endpoint + handle /mcp { + reverse_proxy app:8080 { + # Pass through headers for MCP protocol + header_up X-Forwarded-Proto {scheme} + header_up X-Real-IP {remote_host} + + # Ensure proper content type handling + flush_interval -1 + } + } + + # Fallback - proxy all other requests + handle { + reverse_proxy app:8080 + } + + # Logging + log { + output stdout + format console + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..ebdc446 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,65 @@ +# Gitea MCP Remote — Dockerfile +# Multi-stage build for optimized image size + +FROM python:3.11-slim as builder + +# Set working directory +WORKDIR /build + +# Install build dependencies including git for marketplace dependency +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + gcc \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . +RUN pip install --user --no-cache-dir -r requirements.txt + +# Copy source code +COPY pyproject.toml . +COPY src/ src/ + +# Install package (includes marketplace dependency from git) +RUN pip install --user --no-cache-dir . + +# Production stage +FROM python:3.11-slim + +# Install runtime dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /root/.local /root/.local + +# Copy source code +COPY src/ src/ +COPY pyproject.toml . + +# Make sure scripts in .local are usable +ENV PATH=/root/.local/bin:$PATH + +# Set Python environment variables +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +# Default to port 8080 (Caddy proxies to this) +ENV HTTP_PORT=8080 +ENV HTTP_HOST=0.0.0.0 + +# Expose port +EXPOSE 8080 + +# Health check using curl +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Run the MCP server +CMD ["gitea-mcp-remote"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..549991a --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,66 @@ +services: + # Python MCP Server + app: + build: + context: .. + dockerfile: docker/Dockerfile + image: gitea-mcp-remote:latest + container_name: gitea-mcp-remote-app + restart: unless-stopped + expose: + - "8080" + environment: + # Gitea Configuration (required) + - GITEA_URL=${GITEA_URL} + - GITEA_TOKEN=${GITEA_TOKEN} + - GITEA_OWNER=${GITEA_OWNER} + # Optional Gitea config + - GITEA_REPO=${GITEA_REPO:-} + + # HTTP Server Configuration + - HTTP_HOST=0.0.0.0 + - HTTP_PORT=8080 + + # Authentication (optional - for MCP endpoint) + - AUTH_TOKEN=${AUTH_TOKEN:-} + - MCP_AUTH_MODE=${MCP_AUTH_MODE:-optional} + + # Tool Filtering (optional) + - ENABLED_TOOLS=${ENABLED_TOOLS:-} + - DISABLED_TOOLS=${DISABLED_TOOLS:-} + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + + networks: + - gitea-mcp-network + + # Caddy Reverse Proxy + caddy: + image: caddy:2-alpine + container_name: gitea-mcp-remote-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + app: + condition: service_healthy + networks: + - gitea-mcp-network + +networks: + gitea-mcp-network: + driver: bridge + +volumes: + caddy_data: + caddy_config: diff --git a/pyproject.toml b/pyproject.toml index ac007eb..22500f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ where = ["src"] [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["src/gitea_mcp_remote/tests"] +testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100755 index 0000000..8eaa583 --- /dev/null +++ b/scripts/healthcheck.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Health check script for Gitea MCP Remote +# +# Used by Docker healthcheck and monitoring systems. +# Returns exit code 0 if healthy, 1 if unhealthy. + +set -e + +HOST="${HTTP_HOST:-localhost}" +PORT="${HTTP_PORT:-8080}" +ENDPOINT="http://${HOST}:${PORT}/health" + +# Make request and check response +response=$(curl -sf "$ENDPOINT" 2>/dev/null) || exit 1 + +# Verify JSON response contains status: ok +if echo "$response" | grep -q '"status".*"ok"'; then + exit 0 +else + exit 1 +fi diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..91b3e99 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# Production startup script for Gitea MCP Remote +# +# This script validates the environment and starts the server. + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Starting Gitea MCP Remote...${NC}" + +# Check required environment variables +check_required_env() { + local var_name=$1 + if [ -z "${!var_name}" ]; then + echo -e "${RED}ERROR: Required environment variable $var_name is not set${NC}" + return 1 + fi + echo -e "${GREEN} $var_name is set${NC}" +} + +echo "Checking required environment variables..." +MISSING=0 + +check_required_env "GITEA_URL" || MISSING=1 +check_required_env "GITEA_TOKEN" || MISSING=1 +check_required_env "GITEA_OWNER" || MISSING=1 + +if [ $MISSING -eq 1 ]; then + echo -e "${RED}Missing required environment variables. Exiting.${NC}" + echo "" + echo "Required variables:" + echo " GITEA_URL - Gitea server URL (e.g., https://gitea.example.com)" + echo " GITEA_TOKEN - Gitea API token" + echo " GITEA_OWNER - Default repository owner" + echo "" + echo "Optional variables:" + echo " GITEA_REPO - Default repository name" + echo " AUTH_TOKEN - Bearer token for MCP endpoint authentication" + echo " HTTP_HOST - Server host (default: 0.0.0.0)" + echo " HTTP_PORT - Server port (default: 8080)" + exit 1 +fi + +# Show optional configuration +echo "" +echo "Optional configuration:" +[ -n "$GITEA_REPO" ] && echo -e " ${GREEN}GITEA_REPO: $GITEA_REPO${NC}" || echo -e " ${YELLOW}GITEA_REPO: not set (will use per-request)${NC}" +[ -n "$AUTH_TOKEN" ] && echo -e " ${GREEN}AUTH_TOKEN: (set)${NC}" || echo -e " ${YELLOW}AUTH_TOKEN: not set (no auth required)${NC}" +echo " HTTP_HOST: ${HTTP_HOST:-0.0.0.0}" +echo " HTTP_PORT: ${HTTP_PORT:-8080}" + +# Start the server +echo "" +echo -e "${GREEN}Starting server...${NC}" +exec gitea-mcp-remote diff --git a/src/gitea_mcp_remote/server_http.py b/src/gitea_mcp_remote/server_http.py index 476966c..18d2e59 100644 --- a/src/gitea_mcp_remote/server_http.py +++ b/src/gitea_mcp_remote/server_http.py @@ -135,10 +135,11 @@ def create_app(): app = Starlette(routes=routes) - # Apply middleware (order matters - health bypass first) - app = HealthCheckBypassMiddleware(app) + # Apply middleware (order matters - outermost runs first) + # HealthCheckBypass must wrap BearerAuth so it sets skip_auth flag first if settings.auth_token: - app = BearerAuthMiddleware(app, token=settings.auth_token) + app = BearerAuthMiddleware(app, auth_token=settings.auth_token) + app = HealthCheckBypassMiddleware(app) return app diff --git a/tests/test_config.py b/tests/test_config.py index 156d29a..04a1bab 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -36,9 +36,10 @@ class TestGiteaSettings: assert settings.gitea_token == "test_token" assert settings.gitea_owner == "test_owner" assert settings.gitea_repo == "test_repo" - assert settings.http_host == "127.0.0.1" - assert settings.http_port == 8000 + assert settings.http_host == "0.0.0.0" + assert settings.http_port == 8080 assert settings.auth_token is None + assert settings.mcp_auth_mode == "optional" def test_gitea_url_validation(self): """Test Gitea URL validation.""" @@ -152,21 +153,24 @@ class TestGiteaSettings: ) assert settings.disabled_tools_list == ["tool1", "tool2"] - def test_get_gitea_mcp_env(self): - """Test environment variable generation for wrapped MCP server.""" + def test_mcp_auth_mode_field(self): + """Test mcp_auth_mode field with different values.""" settings = GiteaSettings( gitea_url="https://gitea.example.com", gitea_token="test_token", gitea_owner="test_owner", - gitea_repo="test_repo", + mcp_auth_mode="required", ) - env = settings.get_gitea_mcp_env() + assert settings.mcp_auth_mode == "required" - assert env["GITEA_BASE_URL"] == "https://gitea.example.com" - assert env["GITEA_API_TOKEN"] == "test_token" - assert env["GITEA_DEFAULT_OWNER"] == "test_owner" - assert env["GITEA_DEFAULT_REPO"] == "test_repo" + # Default value + settings_default = GiteaSettings( + gitea_url="https://gitea.example.com", + gitea_token="test_token", + gitea_owner="test_owner", + ) + assert settings_default.mcp_auth_mode == "optional" class TestLoadSettings: diff --git a/tests/test_mcp_endpoints.py b/tests/test_mcp_endpoints.py index f1be6ae..87a2426 100644 --- a/tests/test_mcp_endpoints.py +++ b/tests/test_mcp_endpoints.py @@ -1,4 +1,4 @@ -"""Tests for MCP protocol endpoints.""" +"""Tests for MCP protocol endpoints and health checks.""" import pytest import json import re @@ -26,6 +26,35 @@ def client(mock_env): return TestClient(app) +# ============================================================================= +# Health Endpoint Tests +# ============================================================================= + + +def test_health_endpoint(client): + """Test GET /health returns status ok.""" + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + + +def test_health_endpoint_no_auth_required(mock_env): + """Test health endpoint works even with AUTH_TOKEN set.""" + with patch.dict("os.environ", {**mock_env, "AUTH_TOKEN": "secret123"}): + app = create_app() + client = TestClient(app) + # Health should bypass auth + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +# ============================================================================= +# MCP Protocol Tests +# ============================================================================= + + def parse_sse_message(sse_text: str) -> dict: """Parse SSE message data.""" data_match = re.search(r'data: (.+)', sse_text)