No description
Find a file
DJP 569c7946a7 Expand README with full architecture, client setup (LibreChat, Claude Desktop, Claude Code), troubleshooting
Documents what the server is, how request flow works end-to-end, and the
two SDK gotchas we hit (lifespan-not-mounted + DNS-rebinding 421s) so
future-us doesn't have to rediscover them. Adds explicit per-client
config snippets for LibreChat, Claude Desktop (native http transport
plus mcp-remote stdio bridge fallback), and 'claude mcp add' for Claude
Code. Troubleshooting table covers the actual failures from this deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:40:14 -04:00
deploy Fix MCP 421: allow-list public host for DNS-rebinding protection 2026-05-06 22:35:26 -04:00
.env.example Fix MCP 421: allow-list public host for DNS-rebinding protection 2026-05-06 22:35:26 -04:00
.gitignore Initial mg-mcp: Mailgun MCP server (Streamable HTTPS) for optical-dev 2026-05-06 22:05:38 -04:00
docker-compose.yml Initial mg-mcp: Mailgun MCP server (Streamable HTTPS) for optical-dev 2026-05-06 22:05:38 -04:00
Dockerfile Initial mg-mcp: Mailgun MCP server (Streamable HTTPS) for optical-dev 2026-05-06 22:05:38 -04:00
email_server.py Fix MCP 421: allow-list public host for DNS-rebinding protection 2026-05-06 22:35:26 -04:00
README.md Expand README with full architecture, client setup (LibreChat, Claude Desktop, Claude Code), troubleshooting 2026-05-06 22:40:14 -04:00
requirements.txt Initial mg-mcp: Mailgun MCP server (Streamable HTTPS) for optical-dev 2026-05-06 22:05:38 -04:00

mg-mcp — Mailgun MCP Server

A remote Streamable HTTPS MCP server that exposes a single send_email tool, backed by the existing Mailgun account on mg.oliver.solutions. Any MCP-compatible client (LibreChat, Claude Desktop, Claude Code, custom agents) can call it over HTTPS with a Bearer-token API key.

Live at: https://optical-dev.oliver.solutions/mg-mcp/ Repo: git@bitbucket.org:zlalani/mg-mcp.git


What it does

One MCP tool:

send_email(to_email: str, subject: str, body?: str, html_body?: str) -> str
  • to_email — recipient address
  • subject — subject line
  • body — plain-text body (optional if html_body provided)
  • html_body — HTML body (optional if body provided)

At least one of body / html_body must be set. Both can be set together — clients that don't render HTML fall back to text. Returns a human-readable success or error string.


Architecture

MCP client (LibreChat / Claude Desktop / Claude Code / etc.)
   │  HTTPS
   │  POST /mg-mcp/mcp   Authorization: Bearer <MCP_BEARER_KEY>
   ▼
Google LB (optical-dev.oliver.solutions:443, TLS termination)
   ▼
Apache vhost on the dev box (port 80)
   │  ProxyPass /mg-mcp/  →  http://127.0.0.1:${MG_MCP_PORT}/   (prefix stripped)
   │  ProxyPreserveHost On
   ▼
Docker container "mg-mcp" (uvicorn → FastAPI)
   │  • BearerAuthMiddleware  (validates the API key, exempts /api/health)
   │  • GET  /api/health      → 200 JSON
   │  • POST /mcp             → FastMCP (streamable HTTP, JSON-RPC + SSE)
   ▼
Mailgun REST API (api.mailgun.net/v3/mg.oliver.solutions/messages)
  • Stateless. No DB, no persistent volumes, no per-user state. stateless_http=True on the FastMCP instance.
  • Auth boundary. Every request except GET /api/health requires Authorization: Bearer <MCP_BEARER_KEY>. The bearer key is your own random secret — generated once with openssl rand -hex 32 and stored in the server's .env.
  • No outbound surprises. The only external call the server makes is to Mailgun.

Why all the proxy / Host-header dance

Two non-obvious things in the SDK that bit us during deploy:

  1. Mounted lifespan. FastMCP's streamable_http_app() carries an anyio task group that has to be running before any request is handled. When you app.mount("/", fastmcp_app) on a parent FastAPI, Starlette doesn't run the mounted app's lifespan. We nest mcp.session_manager.run() inside the parent FastAPI's lifespan= instead.
  2. DNS-rebinding protection. The SDK 421s any request whose Host header isn't in an allowlist. Default allows only 127.0.0.1 / localhost. So we need (a) ProxyPreserveHost On in Apache so the container sees optical-dev.oliver.solutions instead of 127.0.0.1:9080, and (b) explicit TransportSecuritySettings(allowed_hosts=[...]) listing our public hostnames.

Deployment (server-side)

One-time bring-up

ssh user@optical-dev.oliver.solutions
sudo git clone git@bitbucket.org:zlalani/mg-mcp.git /opt/mg-mcp
sudo chown -R $USER /opt/mg-mcp
cd /opt/mg-mcp

cp .env.example .env
nano .env
# Fill in:
#   MAILGUN_API_KEY     — the Mailgun private API key (already in Bitwarden)
#   MAILGUN_DOMAIN      — mg.oliver.solutions
#   MAILGUN_FROM        — noreply@mg.oliver.solutions
#   MCP_BEARER_KEY      — generate with: openssl rand -hex 32
# Leave MG_MCP_PORT blank (deploy.sh picks it).

bash deploy/deploy.sh

The script picks a free port in 9080-9099, renders deploy/apache-mg-mcp.conf from the template, builds & starts the container, and polls /api/health. At the end it prints an Include line to add to the shared vhost.

Add the Include line inside </VirtualHost> in /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf, then:

sudo apachectl configtest && sudo systemctl reload apache2

Re-deploys (after pushing changes)

ssh user@optical-dev.oliver.solutions
cd /opt/mg-mcp
bash deploy/deploy.sh

Flags: --no-pull (skip git pull), --no-build (skip docker rebuild), --logs (tail container logs after).


Verification

From any laptop:

# 1. Health (no auth)
curl https://optical-dev.oliver.solutions/mg-mcp/api/health
#   → {"status":"ok","service":"mg-mcp"}

# 2. Auth gate
curl -i https://optical-dev.oliver.solutions/mg-mcp/mcp
#   → 401 {"error":"Missing Bearer token"}

# 3. MCP initialize handshake (replace TOKEN)
TOKEN=...your MCP_BEARER_KEY...
curl -i -N -H "Authorization: Bearer $TOKEN" \
     -H "Accept: application/json, text/event-stream" \
     -H "Content-Type: application/json" \
     -X POST https://optical-dev.oliver.solutions/mg-mcp/mcp \
     -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
#   → 200, content-type: text/event-stream
#   → event: message
#   → data: {"jsonrpc":"2.0","id":1,"result":{...,"serverInfo":{"name":"mg-mcp",...}}}

If all three pass, the server is healthy and ready for clients.


Client setup

You need two things for any client:

  1. Endpoint URL: https://optical-dev.oliver.solutions/mg-mcp/mcp
  2. Bearer token: the value of MCP_BEARER_KEY from /opt/mg-mcp/.env on the server

LibreChat

Settings → MCP Connectors → Add:

Field Value
Name mailgun
MCP Server URL https://optical-dev.oliver.solutions/mg-mcp/mcp
Transport Streamable HTTPS
Authentication API Key
Header Format Bearer
API Key the MCP_BEARER_KEY from .env

Save. The send_email tool should appear in the tool list immediately. Test prompt:

"Send an email to me@example.com with subject 'mg-mcp test' and body 'It works.'"

Claude Desktop

Claude Desktop (≥ 0.7.x) supports remote MCP servers natively. Edit:

OS Config file
macOS ~/Library/Application Support/Claude/claude_desktop_config.json
Windows %APPDATA%\Claude\claude_desktop_config.json
Linux ~/.config/Claude/claude_desktop_config.json

Add to the mcpServers block:

{
  "mcpServers": {
    "mailgun": {
      "url": "https://optical-dev.oliver.solutions/mg-mcp/mcp",
      "transport": "http",
      "headers": {
        "Authorization": "Bearer YOUR_MCP_BEARER_KEY_HERE"
      }
    }
  }
}

Then fully quit Claude Desktop (Cmd+Q on macOS — closing the window isn't enough) and reopen. The 🔧 tools icon should show send_email under the mailgun server.

Older Claude Desktop without remote MCP support

If your version only supports stdio, bridge to the remote server with mcp-remote:

{
  "mcpServers": {
    "mailgun": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://optical-dev.oliver.solutions/mg-mcp/mcp",
        "--header",
        "Authorization: Bearer YOUR_MCP_BEARER_KEY_HERE"
      ]
    }
  }
}

Requires Node.js 18+ on the local machine. mcp-remote proxies stdio ↔ streamable HTTP transparently.

Claude Code (CLI)

One command to register at the user level:

claude mcp add mailgun https://optical-dev.oliver.solutions/mg-mcp/mcp \
  --transport http \
  --header "Authorization: Bearer YOUR_MCP_BEARER_KEY_HERE" \
  --scope user

Scopes:

  • --scope user — available in every project on this machine
  • --scope project — only this project (writes to .mcp.json in the project root, can be committed)
  • --scope local — only this project, only this machine (default)

Verify:

claude mcp list
claude mcp get mailgun

In a Claude Code session, the send_email tool will be auto-discovered. Try:

"Use the mailgun MCP to email daveporter@oliver.agency a one-line summary of what we just changed."

To remove later:

claude mcp remove mailgun

Local development

cd /Users/daveporter/Desktop/CODING-2024/MG-MCP
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

cp .env.example .env
# Fill in MAILGUN_API_KEY, MAILGUN_DOMAIN, MAILGUN_FROM, MCP_BEARER_KEY,
# and PUBLIC_HOSTS=localhost (so the SDK accepts your local Host header).

export $(grep -v '^#' .env | xargs)
python email_server.py
# → uvicorn on http://0.0.0.0:8000

Test against the local server:

curl http://127.0.0.1:8000/api/health
curl -X POST http://127.0.0.1:8000/mcp \
  -H "Authorization: Bearer $MCP_BEARER_KEY" \
  -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Troubleshooting

Symptom Cause Fix
Public URL returns Apache 404 Include line not in shared vhost Add Include /opt/mg-mcp/deploy/apache-mg-mcp.conf inside </VirtualHost>, reload Apache
500 Task group is not initialized FastMCP lifespan not running Confirmed fixed in current code (asynccontextmanager wrapping mcp.session_manager.run()); rebuild the container if you see this
421 Invalid Host header SDK DNS-rebinding rejected the Host Add hostname to PUBLIC_HOSTS in .env, ensure ProxyPreserveHost On is in the rendered apache conf, redeploy
401 Invalid API key Token mismatch Re-copy MCP_BEARER_KEY from server .env — exact string, no quotes, no trailing whitespace
Streaming hangs / truncates Apache buffering responses Check flushpackets=on is in the rendered apache conf; ensure mod_proxy_http enabled (sudo a2enmod proxy proxy_http)
send_email returns Mailgun 401 Wrong Mailgun API key Fix MAILGUN_API_KEY in server .env, then cd /opt/mg-mcp && docker compose up -d to restart container
Client can't see the tool Server didn't initialize cleanly Check docker compose logs app --tail 60 on the server; restart Claude Desktop / re-add LibreChat connector

Files

File Purpose
email_server.py FastAPI + FastMCP HTTP server. All secrets from env vars.
requirements.txt mcp>=1.1.0, fastapi, uvicorn[standard], requests
Dockerfile python:3.12-slim, non-root user
docker-compose.yml Pins name: mg-mcp, binds 127.0.0.1:${MG_MCP_PORT}:8000
.env.example Template for .env (server-side, gitignored)
deploy/deploy.sh Idempotent deploy: port pick, template render, build, health poll
deploy/apache-mg-mcp.conf.tmpl Apache reverse-proxy include template
README.md This file