generated from personal-projects/leo-claude-mktplace
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:
38
docker/.env.example
Normal file
38
docker/.env.example
Normal 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
46
docker/Caddyfile
Normal 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
65
docker/Dockerfile
Normal 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
66
docker/docker-compose.yml
Normal 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:
|
||||||
@@ -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
21
scripts/healthcheck.sh
Executable 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
60
scripts/start.sh
Executable 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user