diff --git a/.gitignore b/.gitignore index 0802b6b..73edff6 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,8 @@ build/ .backend.pid .frontend.pid +# Archived files +backend/archived/ + # Next.js next-env.d.ts \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index b3303ec..fb583e7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/deployment/DEPLOY.md b/backend/deployment/DEPLOY.md new file mode 100644 index 0000000..d6c164d --- /dev/null +++ b/backend/deployment/DEPLOY.md @@ -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 . + +# 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 diff --git a/backend/deployment/mcp-imap.service b/backend/deployment/mcp-imap.service new file mode 100644 index 0000000..c6df902 --- /dev/null +++ b/backend/deployment/mcp-imap.service @@ -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 diff --git a/backend/deployment/nginx-mcp-imap.conf b/backend/deployment/nginx-mcp-imap.conf new file mode 100644 index 0000000..0b40e15 --- /dev/null +++ b/backend/deployment/nginx-mcp-imap.conf @@ -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; diff --git a/backend/src/api_server.py b/backend/src/api_server.py deleted file mode 100644 index 7043e69..0000000 --- a/backend/src/api_server.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/backend/src/mcp_server.py b/backend/src/mcp_server.py index 2b2516a..0dbe832 100644 --- a/backend/src/mcp_server.py +++ b/backend/src/mcp_server.py @@ -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"