Add production deployment for N8N with SSE transport

Production-ready MCP IMAP server for hosted N8N workflows.

Changes:
- SSE transport using create_sse_app() for N8N compatibility
- API key authentication middleware for multi-tenant access
- Systemd service file for auto-restart on Hetzner
- Nginx reverse proxy config with TLS and rate limiting
- Complete deployment guide (DEPLOY.md)
- Removed REST API bridge (api_server.py) - N8N uses MCP protocol

Deployment:
- SSE endpoint: https://imap.maxtheweb.ai/mcp/sse
- Messages endpoint: https://imap.maxtheweb.ai/mcp/messages
- Authentication: Bearer token in Authorization header

Stack: FastMCP 2.13 + uvicorn + nginx + systemd
This commit is contained in:
Soldier 2025-11-17 12:54:57 +00:00
parent 8232a58600
commit 88fbde4542
7 changed files with 417 additions and 403 deletions

3
.gitignore vendored
View File

@ -47,5 +47,8 @@ build/
.backend.pid
.frontend.pid
# Archived files
backend/archived/
# Next.js
next-env.d.ts

View File

@ -3,6 +3,7 @@ MCP_PORT=8000
MCP_HOST=0.0.0.0
# Security Settings
MCP_API_KEYS=your_api_key_1,your_api_key_2,your_api_key_3
RATE_LIMIT_PER_MINUTE=60
MAX_EMAIL_FETCH_SIZE=100
SESSION_TIMEOUT_MINUTES=30

View File

@ -0,0 +1,253 @@
# MCP IMAP Agent - Hetzner Deployment Guide
Deploy production-ready MCP IMAP server for N8N automation.
## Prerequisites
- Hetzner VPS (Ubuntu/Arch Linux)
- Domain: `imap.maxtheweb.ai` pointing to server IP
- Root or sudo access
## Installation Steps
### 1. System Setup
```bash
# Update system (Arch Linux)
sudo pacman -Syu
# Install dependencies
sudo pacman -S python python-pip nginx certbot certbot-nginx git
# Create app directory
sudo mkdir -p /opt/mcp-imap-agent
sudo chown $USER:$USER /opt/mcp-imap-agent
```
### 2. Deploy Application
```bash
# Clone repository
cd /opt/mcp-imap-agent
git clone <your-repo> .
# Create virtual environment
cd backend
python -m venv venv
source venv/bin/activate
# Install dependencies
pip install -r requirements.txt
```
### 3. Configure Environment
```bash
# Create production .env
cp .env.example .env
nano .env
```
**Required .env variables:**
```bash
MCP_PORT=8000
MCP_API_KEYS=generate_random_key_1,generate_random_key_2
RATE_LIMIT_PER_MINUTE=60
LOG_LEVEL=INFO
```
**Generate secure API keys:**
```bash
# Generate 3 random API keys for clients
openssl rand -hex 32
openssl rand -hex 32
openssl rand -hex 32
```
### 4. Install Systemd Service
```bash
# Copy service file
sudo cp deployment/mcp-imap.service /etc/systemd/system/
# Reload systemd
sudo systemctl daemon-reload
# Enable auto-start
sudo systemctl enable mcp-imap
# Start service
sudo systemctl start mcp-imap
# Check status
sudo systemctl status mcp-imap
```
### 5. Configure Nginx
```bash
# Copy nginx config
sudo cp deployment/nginx-mcp-imap.conf /etc/nginx/sites-available/mcp-imap
# Enable site
sudo ln -s /etc/nginx/sites-available/mcp-imap /etc/nginx/sites-enabled/
# Test config
sudo nginx -t
# Reload nginx
sudo systemctl reload nginx
```
### 6. Setup SSL with Certbot
```bash
# Get SSL certificate
sudo certbot --nginx -d imap.maxtheweb.ai
# Certbot auto-configures nginx and auto-renews
```
### 7. Test Deployment
```bash
# Health check (no auth)
curl https://imap.maxtheweb.ai/health
# MCP endpoint (needs API key)
curl -H "Authorization: Bearer YOUR_API_KEY" https://imap.maxtheweb.ai/mcp
```
## N8N Configuration
### Add MCP Server to N8N
N8N uses **MCP over SSE** (Server-Sent Events) protocol.
**Connection Settings:**
- **SSE URL**: `https://imap.maxtheweb.ai/mcp/sse`
- **Messages URL**: `https://imap.maxtheweb.ai/mcp/messages`
- **Authentication**: `Authorization: Bearer YOUR_API_KEY`
**In N8N:**
1. Add "HTTP Request" node or MCP-specific node
2. Configure SSE connection with API key in headers
3. MCP tools are called via JSON-RPC 2.0 protocol
### Example N8N MCP Tool Call
**Search emails:**
```json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "search_emails",
"arguments": {
"username": "user@gmail.com",
"password": "app_password",
"host": "imap.gmail.com",
"folder": "INBOX",
"limit": 10
}
},
"id": 1
}
```
**Send email:**
```json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "send_email",
"arguments": {
"username": "user@gmail.com",
"password": "app_password",
"smtp_host": "smtp.gmail.com",
"to": "recipient@example.com",
"subject": "Automated email",
"body": "This is sent via N8N + MCP"
}
},
"id": 2
}
```
### Available MCP Tools
1. **list_folders** - List IMAP folders
2. **search_emails** - Search with filters (sender, subject, date)
3. **get_email** - Fetch full email content
4. **send_email** - Send email via SMTP
5. **health_check** - Server health status
## Monitoring
```bash
# View logs
sudo journalctl -u mcp-imap -f
# Check nginx logs
sudo tail -f /var/log/nginx/mcp-imap-access.log
sudo tail -f /var/log/nginx/mcp-imap-error.log
# Check resource usage
htop
```
## Security Checklist
- [ ] Firewall configured (only 80, 443, 22 open)
- [ ] SSL certificate installed and auto-renewing
- [ ] Strong API keys generated (32+ chars)
- [ ] Rate limiting enabled (10 req/s)
- [ ] Logs monitored for unauthorized access
- [ ] Regular updates: `sudo pacman -Syu`
## Troubleshooting
### Service won't start
```bash
sudo journalctl -u mcp-imap -n 50
```
### SSL errors
```bash
sudo certbot renew --dry-run
```
### Connection refused
```bash
# Check if service is running
sudo systemctl status mcp-imap
# Check port binding
sudo netstat -tlnp | grep 8000
```
## Maintenance
### Update code
```bash
cd /opt/mcp-imap-agent/backend
git pull
sudo systemctl restart mcp-imap
```
### Rotate API keys
```bash
nano .env # Update MCP_API_KEYS
sudo systemctl restart mcp-imap
```
### Backup
```bash
# Backup .env (contains API keys)
sudo cp /opt/mcp-imap-agent/backend/.env ~/mcp-backup.env.$(date +%Y%m%d)
```
## Support
Issues: https://github.com/yourusername/mcp-imap-agent/issues
Docs: https://modelcontextprotocol.io

View File

@ -0,0 +1,25 @@
[Unit]
Description=MCP IMAP Agent - Email automation server for N8N
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/mcp-imap-agent/backend
Environment="PATH=/opt/mcp-imap-agent/backend/venv/bin"
EnvironmentFile=/opt/mcp-imap-agent/backend/.env
ExecStart=/opt/mcp-imap-agent/backend/venv/bin/python src/mcp_server.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/mcp-imap-agent/backend
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,94 @@
# Nginx configuration for MCP IMAP Agent
# Place in: /etc/nginx/sites-available/mcp-imap
# Enable with: ln -s /etc/nginx/sites-available/mcp-imap /etc/nginx/sites-enabled/
# Get SSL cert: certbot --nginx -d imap.maxtheweb.ai
upstream mcp_backend {
server 127.0.0.1:8000;
keepalive 32;
}
server {
listen 80;
server_name imap.maxtheweb.ai;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name imap.maxtheweb.ai;
# SSL certificates (certbot will populate these)
ssl_certificate /etc/letsencrypt/live/imap.maxtheweb.ai/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/imap.maxtheweb.ai/privkey.pem;
# SSL configuration (Mozilla Intermediate)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Logging
access_log /var/log/nginx/mcp-imap-access.log;
error_log /var/log/nginx/mcp-imap-error.log;
# MCP SSE endpoint for N8N (streaming)
location /mcp/sse {
proxy_pass http://mcp_backend;
# Proxy 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;
# SSE support (critical for MCP protocol)
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
# Chunked transfer encoding for streaming
chunked_transfer_encoding on;
# Rate limiting
limit_req zone=api_limit burst=10 nodelay;
}
# MCP messages endpoint for N8N (posting)
location /mcp/messages {
proxy_pass http://mcp_backend;
# Proxy 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;
# Standard HTTP
proxy_http_version 1.1;
# Rate limiting
limit_req zone=api_limit burst=10 nodelay;
}
# Health check endpoint (no auth required)
location /health {
proxy_pass http://mcp_backend;
access_log off;
}
}
# Rate limiting zone (10 req/sec per IP)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

View File

@ -1,401 +0,0 @@
"""
REST API server for MCP IMAP Agent
Provides HTTP endpoints for the Next.js frontend to communicate with MCP tools
"""
import os
import json
import asyncio
import logging
from typing import Optional, Dict, Any
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI, HTTPException, Request, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, EmailStr
from dotenv import load_dotenv
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
# Import MCP server components
from mcp_server import (
get_session,
IMAPConfig,
list_folders as mcp_list_folders,
search_emails as mcp_search_emails,
get_email as mcp_get_email,
send_email as mcp_send_email,
health_check as mcp_health_check,
Context
)
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Create rate limiter
limiter = Limiter(key_func=get_remote_address)
# Lifespan manager for FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
logger.info("Starting API server...")
yield
# Shutdown
logger.info("Shutting down API server...")
# Create FastAPI app
app = FastAPI(
title="MCP IMAP Agent API",
version="1.0.0",
description="REST API for MCP IMAP Agent",
lifespan=lifespan
)
# Add rate limiter error handler
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:3000").split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Request models
class ConnectionConfig(BaseModel):
"""Email connection configuration"""
host: str
port: int = 993
username: EmailStr
password: str
smtpHost: Optional[str] = None
smtpPort: Optional[int] = 587
class ValidateConnectionRequest(BaseModel):
"""Connection validation request"""
host: str
port: int = 993
username: EmailStr
password: str
class ChatRequest(BaseModel):
"""Chat request with LLM"""
message: str
connection: ConnectionConfig
history: Optional[list] = []
class SearchRequest(BaseModel):
"""Email search request"""
connection: ConnectionConfig
folder: str = "INBOX"
sender: Optional[str] = None
subject: Optional[str] = None
body: Optional[str] = None
since: Optional[str] = None
limit: int = 20
class GetEmailRequest(BaseModel):
"""Get single email request"""
connection: ConnectionConfig
folder: str = "INBOX"
email_id: str
class SendEmailRequest(BaseModel):
"""Send email request"""
connection: ConnectionConfig
to: EmailStr
subject: str
body: str
cc: Optional[str] = None
is_html: bool = False
# Endpoints
@app.get("/")
async def root():
"""Root endpoint"""
return {"message": "MCP IMAP Agent API", "version": "1.0.0"}
@app.get("/health")
@limiter.limit("10/minute")
async def health(request: Request):
"""Health check endpoint"""
return {
"status": "healthy",
"version": "1.0.0",
"timestamp": datetime.now().isoformat()
}
@app.post("/api/validate-connection")
@limiter.limit("5/minute")
async def validate_connection(request: Request, conn: ValidateConnectionRequest):
"""Validate IMAP connection"""
try:
# Test connection by listing folders
result = await mcp_list_folders(
None, # Context not needed for direct calls
username=conn.username,
password=conn.password,
host=conn.host
)
if result.get("success"):
return {
"success": True,
"message": "Connection successful",
"folders": result.get("folders", [])
}
else:
raise HTTPException(status_code=400, detail=result.get("error", "Connection failed"))
except Exception as e:
logger.error(f"Connection validation error: {e}")
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/chat")
@limiter.limit("30/minute")
async def chat(request: Request, chat_req: ChatRequest):
"""
Handle chat messages and execute MCP tools
Returns streaming response with results
"""
async def generate():
try:
# Parse the message to determine action
message = chat_req.message.lower()
conn = chat_req.connection
# Context not needed for REST API calls
# Simple intent detection (in production, use LLM)
if "list folder" in message or "show folder" in message:
result = await mcp_list_folders(
ctx,
username=conn.username,
password=conn.password,
host=conn.host
)
yield json.dumps({"type": "folders", "data": result}) + "\n"
elif "search" in message or "find" in message or "show" in message:
# Extract search parameters from message
search_params = {
"folder": "INBOX",
"limit": 20
}
if "from" in message:
# Simple extraction (in production, use NLP)
parts = message.split("from")
if len(parts) > 1:
sender = parts[1].strip().split()[0]
search_params["sender"] = sender
if "subject" in message:
parts = message.split("subject")
if len(parts) > 1:
subject = parts[1].strip().split()[0]
search_params["subject"] = subject
if "last week" in message:
from datetime import datetime, timedelta
week_ago = datetime.now() - timedelta(days=7)
search_params["since"] = week_ago.strftime("%Y-%m-%d")
result = await mcp_search_emails(
ctx,
username=conn.username,
password=conn.password,
host=conn.host,
**search_params
)
yield json.dumps({"type": "search", "data": result}) + "\n"
elif "send" in message or "draft" in message or "reply" in message:
# For demo, just return a template
yield json.dumps({
"type": "draft",
"data": {
"success": True,
"message": "Ready to compose email. Please provide recipient, subject, and body.",
"template": {
"to": "",
"subject": "",
"body": ""
}
}
}) + "\n"
else:
# Default: search recent emails
result = await mcp_search_emails(
ctx,
username=conn.username,
password=conn.password,
host=conn.host,
folder="INBOX",
limit=10
)
yield json.dumps({
"type": "info",
"data": {
"message": f"Here are your recent emails. You can ask me to search, read, draft, or send emails.",
"emails": result.get("emails", [])
}
}) + "\n"
except Exception as e:
logger.error(f"Chat error: {e}")
yield json.dumps({"type": "error", "data": {"error": str(e)}}) + "\n"
return StreamingResponse(generate(), media_type="application/x-ndjson")
@app.post("/api/search")
@limiter.limit("20/minute")
async def search_emails(request: Request, search_req: SearchRequest):
"""Search emails with filters"""
try:
# Context not needed for REST API calls
conn = search_req.connection
result = await mcp_search_emails(
ctx,
username=conn.username,
password=conn.password,
host=conn.host,
folder=search_req.folder,
sender=search_req.sender,
subject=search_req.subject,
body=search_req.body,
since=search_req.since,
limit=search_req.limit
)
return result
except Exception as e:
logger.error(f"Search error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/get-email")
@limiter.limit("30/minute")
async def get_email(request: Request, email_req: GetEmailRequest):
"""Get full email content"""
try:
# Context not needed for REST API calls
conn = email_req.connection
result = await mcp_get_email(
ctx,
username=conn.username,
password=conn.password,
host=conn.host,
folder=email_req.folder,
email_id=email_req.email_id
)
return result
except Exception as e:
logger.error(f"Get email error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/send")
@limiter.limit("10/minute")
async def send_email_endpoint(request: Request, send_req: SendEmailRequest):
"""Send an email"""
try:
# Context not needed for REST API calls
conn = send_req.connection
# Use SMTP host if provided, otherwise derive from IMAP host
smtp_host = conn.smtpHost
if not smtp_host:
if "gmail" in conn.host:
smtp_host = "smtp.gmail.com"
elif "outlook" in conn.host or "office365" in conn.host:
smtp_host = "smtp.office365.com"
else:
# Try replacing imap with smtp
smtp_host = conn.host.replace("imap.", "smtp.")
result = await mcp_send_email(
ctx,
username=conn.username,
password=conn.password,
smtp_host=smtp_host,
to=send_req.to,
subject=send_req.subject,
body=send_req.body,
cc=send_req.cc,
is_html=send_req.is_html
)
return result
except Exception as e:
logger.error(f"Send email error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/folders")
@limiter.limit("10/minute")
async def list_folders(request: Request, conn: ConnectionConfig):
"""List email folders"""
try:
# Context not needed for REST API calls
result = await mcp_list_folders(
ctx,
username=conn.username,
password=conn.password,
host=conn.host
)
return result
except Exception as e:
logger.error(f"List folders error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/demo-request")
@limiter.limit("5/minute")
async def demo_request(request: Request, data: dict):
"""
Handle demo requests - sends user inquiry to max@maxtheweb.ai
"""
try:
user_email = data.get("email")
user_message = data.get("message", "Demo request")
# TODO: Send email to max@maxtheweb.ai with user's demo request
# For now, just log it
logger.info(f"Demo request from {user_email}: {user_message}")
# In production, you would:
# 1. Send email to max@maxtheweb.ai with user details
# 2. Queue AI processing job
# 3. Send results back to user_email
return {
"success": True,
"message": "Demo request received"
}
except Exception as e:
logger.error(f"Demo request error: {e}")
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("API_PORT", 8001))
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@ -34,6 +34,37 @@ logger = logging.getLogger(__name__)
# Initialize FastMCP server
mcp = FastMCP("mcp-imap-agent")
# Authentication middleware for N8N multi-tenant access
@mcp.add_middleware
async def auth_middleware(request, call_next):
"""Validate API key from N8N clients"""
# Get allowed API keys from environment
allowed_keys = os.getenv("MCP_API_KEYS", "").split(",")
# Extract API key from Authorization header
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
logger.warning(f"Missing or invalid Authorization header from {request.client.host}")
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=401,
content={"error": "Missing or invalid Authorization header"}
)
api_key = auth_header.replace("Bearer ", "")
if api_key not in allowed_keys or not api_key:
logger.warning(f"Invalid API key attempt from {request.client.host}")
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=403,
content={"error": "Invalid API key"}
)
# Valid API key - proceed with request
return await call_next(request)
# Rate limiter for security
limiter = Limiter(key_func=get_remote_address)
@ -446,10 +477,18 @@ async def health_check(ctx: Context) -> Dict[str, Any]:
}
if __name__ == "__main__":
# Run the MCP server
# Run the MCP server with SSE transport (N8N compatibility)
from fastmcp.server.http import create_sse_app
import uvicorn
app = create_sse_app(
mcp,
message_path="/mcp/messages",
sse_path="/mcp/sse"
)
uvicorn.run(
mcp.get_asgi_app(),
app,
host="0.0.0.0",
port=int(os.getenv("MCP_PORT", 8000)),
log_level="info"