MCP Series (07): Enterprise Deployment — Security, Authentication, and Version Management

Three engineering problems when moving an MCP Server from local development to enterprise production: authentication (when to use API Key vs OAuth vs mTLS), Docker deployment (Containerfile + healthcheck + process supervision), and multi-version coexistence (smooth upgrades without dropping active connections). Includes a complete Docker Compose production config and security design checklist.

·6 min read·AI Engineering

Three Gaps Between Local and Production

A local MCP Server takes one command: python server.py. Enterprise production adds three required problems:

  1. Authentication: stdio mode has no auth — any process can connect. Production needs explicit identity verification.
  2. Process supervision: a Python process that dies doesn't restart itself and generates no alert. Production needs a guardian and health checks.
  3. Smooth upgrades: multiple Agent sessions may connect to the same Server simultaneously. Upgrading can't drop those connections.

Authentication Options

API Key (preferred for internal services)

Simplest option, suitable for service-to-service calls inside a corporate network:

import os
from mcp.server import Server
 
EXPECTED_API_KEY = os.environ.get("MCP_API_KEY")
if not EXPECTED_API_KEY:
    raise RuntimeError("MCP_API_KEY environment variable is required")
 
server = Server("jira-tools")

For HTTP transport (non-stdio), validate in middleware:

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
 
class ApiKeyMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        key = (request.headers.get("X-API-Key")
               or request.headers.get("Authorization", "").removeprefix("Bearer "))
        if key != os.environ["MCP_API_KEY"]:
            return Response("Unauthorized", status_code=401)
        return await call_next(request)

OAuth 2.0 (cross-org or user-level permissions)

Use when different users need access to different subsets of data (different Jira projects per user):

The MCP spec (2025) defines OAuth integration interfaces. The Host (Claude Desktop / Claude Code) acquires the OAuth token during user login and passes it to the Server on each MCP connection.

mTLS (high-security internal communication)

For finance, healthcare, or government scenarios with strict data security requirements — mutual certificate verification:

import ssl
 
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.load_cert_chain("/certs/server.crt", "/certs/server.key")
ssl_context.load_verify_locations("/certs/ca.crt")
ssl_context.verify_mode = ssl.CERT_REQUIRED  # require client cert
 
# pass ssl_context to HTTP server

Authentication selection:

Internal service calls (same network)         → API Key + network isolation
Cross-org or user-level permission needs      → OAuth 2.0
High security (finance, healthcare, gov)      → mTLS

Docker Deployment

Minimal Dockerfile

FROM python:3.12-slim
 
WORKDIR /app
 
# Separate dependency install (cache layer)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
 
COPY . .
 
# Don't run as root (security best practice)
RUN useradd -r -s /bin/false mcpuser
USER mcpuser
 
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
    CMD python -c "import sys; print('healthy')" || exit 1
 
CMD ["python", "jira_server.py"]

Production Docker Compose

# docker-compose.prod.yml
version: "3.9"
 
services:
  jira-mcp:
    build: .
    image: jira-mcp-server:1.3.0
    restart: unless-stopped          # auto-restart on crash
    environment:
      - MCP_API_KEY=${MCP_API_KEY}   # injected from .env or Secrets
      - JIRA_URL=${JIRA_URL}
      - JIRA_TOKEN=${JIRA_TOKEN}
      - LOG_LEVEL=INFO
    volumes:
      - ./logs:/app/logs
    networks:
      - mcp-internal                 # internal only
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: "256M"
        reservations:
          memory: "128M"
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "5"
 
networks:
  mcp-internal:
    driver: bridge
    internal: true                   # no external network access

Key configuration points:

  • restart: unless-stopped: restart on crash; stay stopped only on manual stop
  • internal: true: Docker network with no outbound connection — only containers on the same network can reach the Server
  • Resource limits: prevent a buggy Server from consuming host machine resources
  • Log rotation: prevent log files from growing unboundedly

Process Supervision (non-Docker)

# /etc/systemd/system/jira-mcp.service
[Unit]
Description=Jira MCP Server
After=network.target
 
[Service]
Type=simple
User=mcpuser
WorkingDirectory=/opt/jira-mcp
ExecStart=/usr/bin/python3 /opt/jira-mcp/jira_server.py
Restart=always
RestartSec=5
Environment="MCP_API_KEY=your-key"
Environment="JIRA_TOKEN=your-token"
StandardOutput=journal
StandardError=journal
 
[Install]
WantedBy=multi-user.target
systemctl enable jira-mcp
systemctl start jira-mcp
journalctl -u jira-mcp -f    # live log stream

Multi-Version Coexistence and Smooth Upgrades

The Problem

Multiple Agent sessions connect to MCP Server v1.2. You need to release v1.3 (adds a new tool) without dropping those connections.

Strategy: Parallel Versions + Traffic Switch

# docker-compose.prod.yml (parallel versions)
services:
  jira-mcp-stable:
    image: jira-mcp-server:1.2.0     # current stable, serves existing connections
    restart: unless-stopped
    networks: [mcp-internal]
 
  jira-mcp-canary:
    image: jira-mcp-server:1.3.0     # new version, accepts new connections
    restart: unless-stopped
    networks: [mcp-internal]
    deploy:
      replicas: 1

Point the Host config at the canary version first. Monitor it. Then switch stable:

{
  "mcpServers": {
    "jira": {
      "command": "docker",
      "args": ["exec", "jira-mcp-canary", "python", "jira_server.py"]
    }
  }
}

Version Number Rules

MAJOR.MINOR.PATCH
 
MAJOR: breaking changes
  → Remove a tool, rename a tool, change required inputSchema fields
  → Agent code that depends on this tool must update in sync
 
MINOR: backward-compatible additions
  → Add a tool, add optional parameters, extend return fields
  → Existing Agent code continues working; new features opt-in
 
PATCH: behavior-preserving fixes
  → Bug fixes, performance improvements, logging changes
  → Transparent upgrade

Declare the version in Server code:

server = Server(
    "jira-tools",
    version="1.3.0"   # returned to Client in initialize response
)

Deprecation Process

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    if name == "search_jira":   # old tool name
        logger.warning(
            "Tool 'search_jira' is deprecated. "
            "Use 'search_issues' instead. Removing in v2.0.0."
        )
        return await _search_issues(arguments)  # delegate to new implementation

Keep the old tool name for 90 days, log usage, then remove it in the MAJOR version.


Security Design Checklist

Authentication and authorization

  • Credentials injected via environment variables — never hardcoded in code or image
  • Internal services use API Key; cross-org or user-level permissions use OAuth
  • HTTP transport validates in middleware layer, not in tool handlers

Network isolation

  • Docker network set to internal: true — Server has no direct outbound access
  • Only necessary ports exposed (stdio mode requires no open ports)
  • Multiple Servers isolated on separate Docker networks

Tool security

  • Tool inputs have type validation and range checks (Article 04)
  • High-risk tools (writes, external API calls) have audit logs
  • Server's filesystem access restricted to necessary directories

Operations

  • Logs go to stderr, structured format (JSON), with rotation configured
  • restart: unless-stopped or systemd supervision ensures availability
  • Resource limits (CPU/memory) prevent abnormal Server behavior from affecting the host

Summary

  1. Match auth to the scenario: API Key works for internal services, OAuth for cross-org or user-level access, mTLS for regulated industries — don't over-engineer
  2. Docker three-pack: restart: unless-stopped (crash recovery) + internal: true network (isolation) + resource limits (stability)
  3. Smooth upgrades need parallel versions: MINOR versions are backward-compatible and can replace directly; MAJOR versions run old and new in parallel, letting Agent code migrate at its own pace

References


Check out PrimeSkills — a curated marketplace of AI agents and skills that have been validated in real-world, enterprise-grade workflows. No fluff, just what actually works.

Find more useful knowledge and interesting products on my Homepage