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 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 21:11:29 -05:00
parent 9fea0683f7
commit 88c16c840b
10 changed files with 345 additions and 15 deletions

38
docker/.env.example Normal file
View File

@@ -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 <token>" 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=

46
docker/Caddyfile Normal file
View File

@@ -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
}
}
}

65
docker/Dockerfile Normal file
View File

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

66
docker/docker-compose.yml Normal file
View File

@@ -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:

View File

@@ -60,7 +60,7 @@ where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["src/gitea_mcp_remote/tests"] testpaths = ["tests"]
python_files = ["test_*.py"] python_files = ["test_*.py"]
python_classes = ["Test*"] python_classes = ["Test*"]
python_functions = ["test_*"] python_functions = ["test_*"]

21
scripts/healthcheck.sh Executable file
View File

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

60
scripts/start.sh Executable file
View File

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

View File

@@ -135,10 +135,11 @@ def create_app():
app = Starlette(routes=routes) app = Starlette(routes=routes)
# Apply middleware (order matters - health bypass first) # Apply middleware (order matters - outermost runs first)
app = HealthCheckBypassMiddleware(app) # HealthCheckBypass must wrap BearerAuth so it sets skip_auth flag first
if settings.auth_token: if settings.auth_token:
app = BearerAuthMiddleware(app, token=settings.auth_token) app = BearerAuthMiddleware(app, auth_token=settings.auth_token)
app = HealthCheckBypassMiddleware(app)
return app return app

View File

@@ -36,9 +36,10 @@ class TestGiteaSettings:
assert settings.gitea_token == "test_token" assert settings.gitea_token == "test_token"
assert settings.gitea_owner == "test_owner" assert settings.gitea_owner == "test_owner"
assert settings.gitea_repo == "test_repo" assert settings.gitea_repo == "test_repo"
assert settings.http_host == "127.0.0.1" assert settings.http_host == "0.0.0.0"
assert settings.http_port == 8000 assert settings.http_port == 8080
assert settings.auth_token is None assert settings.auth_token is None
assert settings.mcp_auth_mode == "optional"
def test_gitea_url_validation(self): def test_gitea_url_validation(self):
"""Test Gitea URL validation.""" """Test Gitea URL validation."""
@@ -152,21 +153,24 @@ class TestGiteaSettings:
) )
assert settings.disabled_tools_list == ["tool1", "tool2"] assert settings.disabled_tools_list == ["tool1", "tool2"]
def test_get_gitea_mcp_env(self): def test_mcp_auth_mode_field(self):
"""Test environment variable generation for wrapped MCP server.""" """Test mcp_auth_mode field with different values."""
settings = GiteaSettings( settings = GiteaSettings(
gitea_url="https://gitea.example.com", gitea_url="https://gitea.example.com",
gitea_token="test_token", gitea_token="test_token",
gitea_owner="test_owner", 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" # Default value
assert env["GITEA_API_TOKEN"] == "test_token" settings_default = GiteaSettings(
assert env["GITEA_DEFAULT_OWNER"] == "test_owner" gitea_url="https://gitea.example.com",
assert env["GITEA_DEFAULT_REPO"] == "test_repo" gitea_token="test_token",
gitea_owner="test_owner",
)
assert settings_default.mcp_auth_mode == "optional"
class TestLoadSettings: class TestLoadSettings:

View File

@@ -1,4 +1,4 @@
"""Tests for MCP protocol endpoints.""" """Tests for MCP protocol endpoints and health checks."""
import pytest import pytest
import json import json
import re import re
@@ -26,6 +26,35 @@ def client(mock_env):
return TestClient(app) 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: def parse_sse_message(sse_text: str) -> dict:
"""Parse SSE message data.""" """Parse SSE message data."""
data_match = re.search(r'data: (.+)', sse_text) data_match = re.search(r'data: (.+)', sse_text)