From 322ed78427532069f3c0e2e318decd077b1d74e7 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 11 Jan 2026 14:27:50 -0700 Subject: [PATCH] Add Docker deployment with streamable-http transport for hosted MCP - Add Dockerfile with multi-stage build using uv - Add docker-compose.yml with caddy-docker-proxy labels for /mcp endpoint - Add .env.example for deployment configuration - Update Makefile with docker-* targets - Update server.py to support MCP_TRANSPORT env var: - 'stdio' (default): Local CLI usage with Claude Code - 'streamable-http': Hosted HTTP mode behind reverse proxy Hosted server will be available at: https://mcwaddams.supported.systems/mcp --- .env.example | 18 ++++++++++ Dockerfile | 76 +++++++++++++++++++++++++++++++++++++++++ Makefile | 73 +++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 49 ++++++++++++++++++++++++++ src/mcwaddams/server.py | 44 +++++++++++++++++++++--- 5 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e0ef92 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# mcwaddams MCP Server - Environment Configuration +# Copy to .env and customize + +# Docker Compose project name (prevents container name conflicts) +COMPOSE_PROJECT_NAME=mcwaddams + +# Hostname for caddy-docker-proxy +MCWADDAMS_HOST=mcwaddams.supported.systems + +# Debug mode (enables verbose logging) +DEBUG=false + +# Transport mode: stdio (local), streamable-http (hosted) +MCP_TRANSPORT=streamable-http + +# Server binding (for streamable-http mode) +MCP_HOST=0.0.0.0 +MCP_PORT=8000 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f2f4b81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,76 @@ +# mcwaddams MCP Server - Production Dockerfile +# "I was told there would be document extraction..." + +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy dependency files first for better caching +COPY pyproject.toml uv.lock* ./ + +# Install dependencies (without the project itself) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-install-project --no-dev + +# Copy source code +COPY src/ ./src/ +COPY README.md LICENSE ./ + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev + +# ============================================ +# Production image +# ============================================ +FROM python:3.12-slim-bookworm AS production + +WORKDIR /app + +# Create non-root user +RUN groupadd --gid 1000 mcwaddams && \ + useradd --uid 1000 --gid mcwaddams --shell /bin/bash --create-home mcwaddams + +# Install runtime dependencies (for some document processing) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libxml2 \ + libxslt1.1 \ + && rm -rf /var/lib/apt/lists/* + +# Copy virtual environment from builder +COPY --from=builder /app/.venv /app/.venv + +# Copy source +COPY --from=builder /app/src /app/src +COPY --from=builder /app/README.md /app/LICENSE ./ + +# Set environment +ENV PATH="/app/.venv/bin:$PATH" +ENV PYTHONPATH="/app/src" +ENV PYTHONUNBUFFERED=1 +ENV UV_COMPILE_BYTECODE=1 + +# Default to streamable-http transport for hosted mode +ENV MCP_TRANSPORT=streamable-http +ENV MCP_HOST=0.0.0.0 +ENV MCP_PORT=8000 + +# Temp directory for document processing +RUN mkdir -p /tmp/mcwaddams && chown mcwaddams:mcwaddams /tmp/mcwaddams +ENV OFFICE_TEMP_DIR=/tmp/mcwaddams + +USER mcwaddams + +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')" || exit 1 + +# Run the server +CMD ["python", "-m", "mcwaddams.server"] diff --git a/Makefile b/Makefile index d6516ff..de64db2 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,24 @@ # Makefile for MCP Office Tools # Provides convenient commands for testing, development, and dashboard generation -.PHONY: help test test-dashboard test-pytest test-torture view-dashboard clean install format lint type-check +.PHONY: help test test-dashboard test-pytest test-torture view-dashboard clean install format lint type-check \ + docker-build docker-up docker-down docker-logs docker-shell docker-restart docker-clean dev-http # Default target - show help help: @echo "MCP Office Tools - Available Commands" @echo "======================================" @echo "" - @echo "Testing & Dashboard:" + @echo "Docker / Hosted Server:" + @echo " make docker-build - Build Docker image" + @echo " make docker-up - Start server (detached, with caddy)" + @echo " make docker-down - Stop server" + @echo " make docker-logs - View server logs" + @echo " make docker-restart - Restart server" + @echo " make docker-clean - Remove Docker resources" + @echo " make dev-http - Run locally with HTTP transport" + @echo "" + @echo "Testing & Dashboard: @echo " make test - Run all tests with dashboard generation" @echo " make test-dashboard - Alias for 'make test'" @echo " make test-pytest - Run only pytest tests" @@ -111,6 +121,65 @@ build: @uv build @echo "โœ… Build complete! Packages in dist/" +# ==================================== +# Docker / Hosted Server Commands +# ==================================== + +# Ensure .env exists for Docker +.env: + @if [ ! -f .env ]; then \ + echo "Creating .env from .env.example..."; \ + cp .env.example .env; \ + fi + +# Build Docker image +docker-build: + @echo "๐Ÿณ Building mcwaddams Docker image..." + @docker compose build + @echo "โœ… Docker build complete!" + +# Start server (production mode with caddy-docker-proxy) +docker-up: .env + @echo "๐Ÿš€ Starting mcwaddams MCP server..." + @docker compose up -d + @sleep 2 + @docker compose logs --tail=20 + @echo "" + @echo "โœ… Server running! Connect via:" + @echo " https://$${MCWADDAMS_HOST:-mcwaddams.supported.systems}/mcp" + +# Stop server +docker-down: + @echo "๐Ÿ›‘ Stopping mcwaddams server..." + @docker compose down + @echo "โœ… Server stopped" + +# View server logs +docker-logs: + @docker compose logs -f + +# Open shell in running container +docker-shell: + @docker compose exec mcwaddams /bin/bash + +# Restart server +docker-restart: docker-down docker-up + +# Clean up Docker resources +docker-clean: + @echo "๐Ÿงน Cleaning Docker resources..." + @docker compose down -v --rmi local + @echo "โœ… Docker cleanup complete!" + +# Development: run locally with streamable-http transport +dev-http: + @echo "๐ŸŒ Starting mcwaddams with streamable-http transport..." + @MCP_TRANSPORT=streamable-http MCP_HOST=127.0.0.1 MCP_PORT=8000 uv run python -m mcwaddams.server + +# ==================================== +# Project Information +# ==================================== + # Show project info info: @echo "MCP Office Tools - Project Information" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..488f719 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +# mcwaddams MCP Server - Docker Compose +# "I could set the building on fire..." + +services: + mcwaddams: + build: + context: . + dockerfile: Dockerfile + target: production + container_name: mcwaddams-mcp + restart: unless-stopped + environment: + - MCP_TRANSPORT=streamable-http + - MCP_HOST=0.0.0.0 + - MCP_PORT=8000 + - DEBUG=${DEBUG:-false} + - OFFICE_TEMP_DIR=/tmp/mcwaddams + volumes: + # Temp directory for document processing + - mcwaddams-temp:/tmp/mcwaddams + networks: + - caddy + labels: + # Caddy-docker-proxy labels for /mcp endpoint + caddy: ${MCWADDAMS_HOST:-mcwaddams.supported.systems} + caddy.@mcp.path: /mcp/* + caddy.@mcp.path_strip: /mcp + caddy.handle: "@mcp" + caddy.handle.reverse_proxy: "{{upstreams 8000}}" + caddy.handle.reverse_proxy.flush_interval: "-1" + caddy.handle.reverse_proxy.transport: "http" + caddy.handle.reverse_proxy.transport.read_timeout: "0" + caddy.handle.reverse_proxy.transport.write_timeout: "0" + caddy.handle.reverse_proxy.stream_timeout: "24h" + caddy.handle.reverse_proxy.header_up.Connection: "{http.request.header.Connection}" + caddy.handle.reverse_proxy.header_up.Upgrade: "{http.request.header.Upgrade}" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +volumes: + mcwaddams-temp: + +networks: + caddy: + external: true diff --git a/src/mcwaddams/server.py b/src/mcwaddams/server.py index 4586623..a5f6a8e 100644 --- a/src/mcwaddams/server.py +++ b/src/mcwaddams/server.py @@ -554,10 +554,46 @@ This is a complete editorial workflow for manuscript review.""" def main(): - """Entry point for the MCP Office Tools server.""" - # CRITICAL: show_banner=False is required for stdio transport! - # FastMCP's banner prints ASCII art to stdout which breaks JSON-RPC protocol - app.run(show_banner=False) + """Entry point for the MCP Office Tools server. + + Supports two transport modes via MCP_TRANSPORT environment variable: + - 'stdio' (default): For local CLI usage with Claude Code + - 'streamable-http': For hosted HTTP mode behind reverse proxy + + Additional env vars for streamable-http mode: + - MCP_HOST: Bind address (default: 0.0.0.0) + - MCP_PORT: Port number (default: 8000) + """ + transport = os.environ.get("MCP_TRANSPORT", "stdio").lower() + + if transport == "streamable-http": + # HTTP transport for hosted/Docker mode + host = os.environ.get("MCP_HOST", "0.0.0.0") + port = int(os.environ.get("MCP_PORT", "8000")) + + try: + from importlib.metadata import version + pkg_version = version("mcwaddams") + except Exception: + pkg_version = "0.1.0" + + print(f"๐Ÿ–จ๏ธ mcwaddams v{pkg_version}") + print(f"๐Ÿ“‹ MCP Office Tools - Document Extraction Server") + print(f"๐ŸŒ Starting streamable-http transport on {host}:{port}") + print(f" Endpoint: http://{host}:{port}/mcp") + print() + + app.run( + transport="streamable-http", + host=host, + port=port, + ) + else: + # Default stdio transport for local CLI usage + # CRITICAL: show_banner=False is required for stdio transport! + # FastMCP's banner prints ASCII art to stdout which breaks JSON-RPC protocol + app.run(show_banner=False) + if __name__ == "__main__": main() \ No newline at end of file