Comprehensive security guide for deploying Fathom MCP in different environments.
Fathom MCP follows the principle of separation of concerns:
- MCP Server = Read-only access to local documents
- Authentication = External tools (reverse proxy, VPN, OAuth provider)
- Cloud Sync = External tools (rclone, desktop clients)
This architectural decision follows industry best practices:
- ✅ Don't roll your own auth/crypto
- ✅ Use well-tested, established libraries
- ✅ Single responsibility principle
- ✅ Defense in depth
Fathom MCP does NOT include built-in authentication for HTTP transport.
This is an intentional architectural decision:
- Authentication should be handled by dedicated, well-tested tools
- Most users run locally via stdio transport (no network access)
- Enterprise users have existing auth infrastructure (OAuth, SSO)
- Reduces attack surface and maintenance burden
Never expose HTTP transport directly to the internet without protection.
Use stdio transport for local AI agents (Claude Desktop, custom clients).
Configuration:
# config.yaml
transport:
type: "stdio" # Default - no network accessSecurity:
- ✅ No network exposure
- ✅ No authentication needed
- ✅ Process-level isolation
- ✅ OS-level permissions
Use when:
- Running Claude Desktop locally
- Personal knowledge base
- Development/testing
Use HTTP transport on localhost only.
Configuration:
# config.yaml
transport:
type: "streamable-http"
host: "127.0.0.1" # Localhost only
port: 8765Security:
- ✅ No external network access
- ✅ Only same-machine clients
⚠️ No authentication (trust localhost)
Use when:
- Local web applications
- Same-machine AI agents
- Development environments
Use reverse proxy with authentication for remote access.
This is the recommended approach for production deployments.
Why Caddy:
- Automatic HTTPS (Let's Encrypt)
- Built-in basic auth
- Simple configuration
- One binary, no dependencies
Install:
# macOS
brew install caddy
# Ubuntu/Debian
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddyGenerate password hash:
caddy hash-password
# Enter your password, get bcrypt hashCaddyfile:
# /etc/caddy/Caddyfile
mcp.yourdomain.com {
# Basic authentication
basicauth {
alice $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpBnZgr5k6V.vS5pLAiNYg6
bob $2a$14$Rlw1lVKi7D5XLIxZ.OQzMeJ0grRPV.8K6j3LVLhP2sC6Y9Gqo5xyO
}
# Reverse proxy to MCP server
reverse_proxy localhost:8765 {
# Health check
health_uri /_health
health_interval 30s
health_timeout 5s
}
# Security headers (automatic)
# - HTTPS redirect
# - HSTS
# - TLS 1.3
}Start Caddy:
sudo systemctl enable caddy
sudo systemctl start caddy
# Check logs
sudo journalctl -u caddy -fTest:
# Without auth - should fail
curl https://mcp.yourdomain.com/_health
# With auth - should work
curl -u alice:yourpassword https://mcp.yourdomain.com/_healthWhy Nginx:
- Industry standard
- High performance
- Extensive ecosystem
- Fine-grained control
Install:
# Ubuntu/Debian
sudo apt install nginx apache2-utils
# macOS
brew install nginxGenerate password file:
# Create password for user 'admin'
sudo htpasswd -c /etc/nginx/.htpasswd admin
# Enter password when promptedNginx configuration:
# /etc/nginx/sites-available/fathom-mcp
upstream fathom_mcp {
server localhost:8765;
keepalive 32;
}
server {
listen 80;
server_name mcp.yourdomain.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name mcp.yourdomain.com;
# SSL certificates (use certbot for Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Basic authentication
auth_basic "MCP Server";
auth_basic_user_file /etc/nginx/.htpasswd;
# Health check (no auth required)
location /_health {
auth_basic off;
proxy_pass http://fathom_mcp;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# MCP endpoints (auth required)
location / {
proxy_pass http://fathom_mcp;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Buffering
proxy_buffering off;
proxy_request_buffering off;
}
# Rate limiting
limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=10r/s;
limit_req zone=mcp_limit burst=20 nodelay;
# Logging
access_log /var/log/nginx/fathom-mcp-access.log;
error_log /var/log/nginx/fathom-mcp-error.log;
}Enable and test:
# Test configuration
sudo nginx -t
# Enable site
sudo ln -s /etc/nginx/sites-available/fathom-mcp /etc/nginx/sites-enabled/
# Reload Nginx
sudo systemctl reload nginx
# Get SSL certificate
sudo certbot --nginx -d mcp.yourdomain.comWhy Traefik:
- Automatic service discovery
- Native Docker/Kubernetes support
- Automatic HTTPS
- Dynamic configuration
docker-compose.yaml:
version: "3.8"
services:
traefik:
image: traefik:v3.0
command:
- "--api.insecure=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=your-email@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
networks:
- mcp
fathom-mcp:
build: .
environment:
FMCP_TRANSPORT__TYPE: "streamable-http"
FMCP_TRANSPORT__HOST: "0.0.0.0"
FMCP_TRANSPORT__PORT: "8765"
volumes:
- ./documents:/knowledge:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.mcp.rule=Host(`mcp.yourdomain.com`)"
- "traefik.http.routers.mcp.entrypoints=websecure"
- "traefik.http.routers.mcp.tls.certresolver=letsencrypt"
# Basic auth middleware
- "traefik.http.middlewares.mcp-auth.basicauth.users=admin:$$apr1$$hash..."
- "traefik.http.routers.mcp.middlewares=mcp-auth"
# Service
- "traefik.http.services.mcp.loadbalancer.server.port=8765"
networks:
- mcp
networks:
mcp:
driver: bridgeGenerate basic auth hash for Traefik:
# Install htpasswd
sudo apt install apache2-utils
# Generate hash (escape $ as $$)
echo $(htpasswd -nb admin yourpassword) | sed 's/\$/\$$/g'Use VPN for secure remote access without exposing ports.
This is the best solution for remote access to private servers.
Why Tailscale:
- Zero-config mesh VPN
- Automatic encryption (WireGuard)
- Per-device authentication
- Works through NAT/firewalls
Install on server:
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
# Authenticate
sudo tailscale up
# Get Tailscale IP
tailscale ip -4
# Example: 100.101.102.103Configure MCP server:
# config.yaml
transport:
type: "streamable-http"
host: "0.0.0.0" # Listen on all interfaces (safe with Tailscale)
port: 8765Start MCP server:
fathom-mcp --config config.yamlConnect from client:
# Install Tailscale on client device
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Access MCP server via Tailscale IP
curl http://100.101.102.103:8765/_healthClaude Desktop config (on client):
{
"mcpServers": {
"knowledge": {
"command": "curl",
"args": [
"-X", "POST",
"http://100.101.102.103:8765/mcp",
"-H", "Content-Type: application/json"
]
}
}
}Security:
- ✅ Encrypted tunnel (WireGuard)
- ✅ Device authentication
- ✅ No exposed ports
- ✅ Works through firewalls
- ✅ Access control via Tailscale admin
For self-hosted VPN with full control.
Server setup:
# Install WireGuard
sudo apt install wireguard
# Generate keys
wg genkey | tee privatekey | wg pubkey > publickey
# Configure
sudo nano /etc/wireguard/wg0.confwg0.conf:
[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = <server-private-key>
# Client peer
[Peer]
PublicKey = <client-public-key>
AllowedIPs = 10.0.0.2/32Start WireGuard:
sudo systemctl enable wg-quick@wg0
sudo systemctl start wg-quick@wg0Configure MCP to listen on VPN interface:
transport:
type: "streamable-http"
host: "10.0.0.1" # VPN IP
port: 8765Use OAuth 2.1 for enterprise environments with existing SSO/IdP.
Based on MCP official authorization guide.
When to use:
- Multi-tenant deployments
- Enterprise SSO integration (Okta, Auth0, Azure AD)
- Audit requirements (who accessed what)
- Fine-grained permissions per user
Implementation:
Uses MCP SDK's built-in TokenVerifier (NOT custom implementation):
# src/fathom_mcp/auth.py (example)
from mcp.server.auth.provider import TokenVerifier, AccessToken
from mcp.server.auth.settings import AuthSettings
class IntrospectionTokenVerifier(TokenVerifier):
"""Token verifier using OAuth 2.0 Token Introspection (RFC 7662)."""
def __init__(self, introspection_endpoint: str, client_id: str, client_secret: str):
self.introspection_endpoint = introspection_endpoint
self.client_id = client_id
self.client_secret = client_secret
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify token via authorization server introspection."""
import httpx
async with httpx.AsyncClient() as client:
response = await client.post(
self.introspection_endpoint,
data={
"token": token,
"client_id": self.client_id,
"client_secret": self.client_secret,
},
)
if response.status_code != 200:
return None
data = response.json()
if not data.get("active", False):
return None
return AccessToken(
token=token,
client_id=data.get("client_id", "unknown"),
scopes=data.get("scope", "").split() if data.get("scope") else [],
expires_at=data.get("exp"),
)Configuration:
# config.yaml
auth:
enabled: true
provider: "oauth"
issuer_url: "https://auth.yourcompany.com"
client_id: "fathom-mcp"
client_secret: "${OAUTH_CLIENT_SECRET}" # From environment
required_scopes: ["mcp:tools", "mcp:resources"]Supported providers:
- Keycloak (open-source)
- Auth0
- Okta
- Azure AD / Entra ID
- Google Workspace
- Any OAuth 2.1 / OIDC compliant provider
See: MCP Authorization Tutorial for complete setup.
# docker-compose.secure.yaml
version: "3.8"
services:
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- mcp
restart: unless-stopped
fathom-mcp:
build: .
environment:
FMCP_TRANSPORT__TYPE: "streamable-http"
FMCP_TRANSPORT__HOST: "0.0.0.0"
FMCP_TRANSPORT__PORT: "8765"
FMCP_KNOWLEDGE__ROOT: "/knowledge"
volumes:
- ./documents:/knowledge:ro
- ./config.yaml:/app/config.yaml:ro
# Don't expose ports externally - only via Caddy
expose:
- "8765"
networks:
- mcp
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "/app/docker/healthcheck.py"]
interval: 30s
timeout: 5s
retries: 3
networks:
mcp:
driver: bridge
volumes:
caddy_data:
caddy_config:Caddyfile:
{
# Global options
email your-email@example.com
}
mcp.yourdomain.com {
# Basic auth
basicauth {
admin $2a$14$hash...
}
# Reverse proxy
reverse_proxy fathom-mcp:8765 {
health_uri /_health
health_interval 30s
}
# Rate limiting
rate_limit {
zone dynamic {
key {remote_host}
events 100
window 1m
}
}
}Run:
docker-compose -f docker-compose.secure.yaml up -d❌ DON'T:
transport:
type: "streamable-http"
host: "0.0.0.0" # Exposed to internet
port: 8765
enable_cors: true
allowed_origins: ["*"] # Anyone can access!✅ DO:
# Option A: Local only
transport:
type: "stdio"
# Option B: Localhost only
transport:
type: "streamable-http"
host: "127.0.0.1"
# Option C: Behind reverse proxy
transport:
type: "streamable-http"
host: "127.0.0.1" # Nginx/Caddy on same machine
# Option D: VPN isolated
transport:
type: "streamable-http"
host: "10.0.0.1" # VPN IP onlyAlways terminate TLS at reverse proxy:
- Caddy: Automatic Let's Encrypt
- Nginx: Use
certbot --nginx - Traefik: Automatic ACME
Protect against abuse:
# Nginx
limit_req_zone $binary_remote_addr zone=mcp:10m rate=10r/s;
limit_req zone=mcp burst=20 nodelay;# Caddy
rate_limit {
zone dynamic {
key {remote_host}
events 100
window 1m
}
}# Nginx
tail -f /var/log/nginx/fathom-mcp-access.log
# Caddy
journalctl -u caddy -f
# Look for suspicious patterns
grep "401\|403\|500" /var/log/nginx/fathom-mcp-access.log# Use environment variables
export BASIC_AUTH_PASSWORD="strong-random-password"
# Or use secret management
# - HashiCorp Vault
# - AWS Secrets Manager
# - Docker Secrets
# Never commit credentials
echo ".htpasswd" >> .gitignore
echo "*.env" >> .gitignore# Update system packages
sudo apt update && sudo apt upgrade
# Update Docker images
docker-compose pull
docker-compose up -d
# Update Fathom MCP
pip install --upgrade fathom-mcp# Allow only necessary ports
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
# Block direct access to MCP port
# (only allow via reverse proxy)# Read-only document mounting
volumes:
- ./documents:/knowledge:ro # :ro = read-only# Run as non-root user
docker run --user 1000:1000 fathom-mcpCORS protects against browser-based attacks ONLY.
It does NOT protect against:
- Direct HTTP requests (curl, wget, Python requests)
- Malicious servers
- API abuse
# config.yaml
transport:
enable_cors: true
allowed_origins:
- "http://localhost:3000"
- "http://localhost:8080"transport:
enable_cors: true
allowed_origins:
- "https://app.yourdomain.com"
- "https://dashboard.yourdomain.com"
# NEVER use "*" in production!# CORS only blocks browsers
# This bypasses CORS entirely:
curl -X POST http://your-server:8765/mcp \
-H "Content-Type: application/json" \
-d '{"method": "search_documents", ...}'
# ^ No browser, no CORS protectionSolution: Always use authentication (reverse proxy, VPN, OAuth).
Problem:
Error: Connection refused to localhost:8765
Solution:
# Check if server is running
curl http://localhost:8765/_health
# Check if port is listening
netstat -tlnp | grep 8765
# Check server logs
journalctl -u fathom-mcp -fProblem:
HTTP/1.1 401 Unauthorized
Solution:
# Verify basic auth credentials
curl -u username:password http://localhost/_health
# Check .htpasswd file
cat /etc/nginx/.htpasswd
# Regenerate password
htpasswd -c /etc/nginx/.htpasswd usernameProblem:
SSL certificate problem: unable to get local issuer certificate
Solution:
# Renew Let's Encrypt certificate
sudo certbot renew
# Check certificate expiry
openssl s_client -connect mcp.yourdomain.com:443 -servername mcp.yourdomain.com
# Force HTTPS redirect
# See Nginx/Caddy examples aboveProblem:
504 Gateway Timeout
Solution:
# Increase Nginx timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;# Increase MCP server timeout
search:
timeout_seconds: 60Step 1: Start with stdio (development)
transport:
type: "stdio"Step 2: Test with HTTP localhost
transport:
type: "streamable-http"
host: "127.0.0.1"Step 3: Add reverse proxy
# Install Caddy
sudo apt install caddy
# Configure basic auth
# See examples aboveStep 4: Get SSL certificate
# Automatic with Caddy
# Just update Caddyfile domainStep 5: Monitor and harden
# Set up monitoring
# Configure rate limits
# Review access logs- Integration Guide - Client setup and configuration
- Configuration Reference - All configuration options
- Cloud Sync Guide - External sync strategies
- MCP Authorization Tutorial - OAuth 2.1 setup
| Use Case | Recommended Solution | Security Level |
|---|---|---|
| Local development | stdio transport | 🟢 High |
| Same-machine access | HTTP on 127.0.0.1 | 🟢 High |
| Remote personal use | VPN (Tailscale) | 🟢 High |
| Team deployment | Reverse proxy + basic auth | 🟡 Medium-High |
| Enterprise deployment | OAuth 2.1 with SSO | 🟢 High |
| Public API (not recommended) | OAuth + rate limiting + WAF | 🟡 Medium |
Key principle: Authentication is NOT the MCP server's responsibility. Use external, proven tools.