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> |
||
|---|---|---|
| deploy | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| email_server.py | ||
| README.md | ||
| requirements.txt | ||
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 addresssubject— subject linebody— plain-text body (optional ifhtml_bodyprovided)html_body— HTML body (optional ifbodyprovided)
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=Trueon the FastMCP instance. - Auth boundary. Every request except
GET /api/healthrequiresAuthorization: Bearer <MCP_BEARER_KEY>. The bearer key is your own random secret — generated once withopenssl rand -hex 32and 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:
- Mounted lifespan. FastMCP's
streamable_http_app()carries an anyio task group that has to be running before any request is handled. When youapp.mount("/", fastmcp_app)on a parent FastAPI, Starlette doesn't run the mounted app's lifespan. We nestmcp.session_manager.run()inside the parent FastAPI'slifespan=instead. - DNS-rebinding protection. The SDK 421s any request whose
Hostheader isn't in an allowlist. Default allows only127.0.0.1/localhost. So we need (a)ProxyPreserveHost Onin Apache so the container seesoptical-dev.oliver.solutionsinstead of127.0.0.1:9080, and (b) explicitTransportSecuritySettings(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:
- Endpoint URL:
https://optical-dev.oliver.solutions/mg-mcp/mcp - Bearer token: the value of
MCP_BEARER_KEYfrom/opt/mg-mcp/.envon 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.jsonin 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 |