No description
  • Shell 57%
  • Python 39.8%
  • Dockerfile 3.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
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) <[email protected]>
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: [email protected]: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 [email protected]
sudo git clone [email protected]: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        — [email protected]
#   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 [email protected]
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 [email protected] 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 [email protected] 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