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>
This commit is contained in:
DJP 2026-05-06 22:40:14 -04:00
parent 5e1a88b53c
commit 569c7946a7

310
README.md
View file

@ -1,119 +1,142 @@
# mg-mcp — Mailgun MCP Server
A Streamable HTTPS MCP server that exposes a single `send_email` tool, backed by Mailgun (`mg.oliver.solutions`).
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.
Deployed at: **`https://optical-dev.oliver.solutions/mg-mcp/`**
**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
```
LibreChat (or any MCP client)
│ HTTPS, header: Authorization: Bearer <MCP_BEARER_KEY>
MCP client (LibreChat / Claude Desktop / Claude Code / etc.)
│ HTTPS
│ POST /mg-mcp/mcp Authorization: Bearer <MCP_BEARER_KEY>
Apache shared vhost on optical-dev.oliver.solutions
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 → FastMCP
│ GET /api/health (no auth)
│ POST /mcp (MCP streamable HTTP, requires Bearer)
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 API
Mailgun REST API (api.mailgun.net/v3/mg.oliver.solutions/messages)
```
Stateless. No DB. No persistent volumes.
- **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.
## Local development
### 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
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then edit and fill in real values
export $(grep -v '^#' .env | xargs)
python email_server.py # serves on http://127.0.0.1:8000
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
```
Quick health check:
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:
```bash
curl http://127.0.0.1:8000/api/health
sudo apachectl configtest && sudo systemctl reload apache2
```
## Deploying to optical-dev.oliver.solutions
### Re-deploys (after pushing changes)
**One-time setup:**
1. From a laptop, push this repo to GitHub:
```bash
cd /Users/daveporter/Desktop/CODING-2024/MG-MCP
git init && git add . && git commit -m "Initial mg-mcp"
gh repo create OliverGroup/mg-mcp --private --source=. --push
```
2. SSH into the dev server and clone to `/opt/mg-mcp/`:
```bash
ssh user@optical-dev.oliver.solutions
sudo git clone git@github.com:OliverGroup/mg-mcp.git /opt/mg-mcp
sudo chown -R $USER /opt/mg-mcp
cd /opt/mg-mcp
```
3. Create `.env` from the template and fill in real values:
```bash
cp .env.example .env
nano .env
```
- `MAILGUN_API_KEY` — the existing Mailgun private API key for `mg.oliver.solutions`
- `MAILGUN_DOMAIN``mg.oliver.solutions`
- `MAILGUN_FROM``noreply@mg.oliver.solutions` (or any verified address)
- `MCP_BEARER_KEY` — generate with `openssl rand -hex 32`. Share with anyone configuring an MCP client.
- Leave `MG_MCP_PORT` blank — `deploy.sh` will auto-pick.
4. Run the deploy:
```bash
bash deploy/deploy.sh
```
It will print the chosen port, the public URL, and an Apache `Include` line.
5. Add the printed `Include` line **inside** `</VirtualHost>` of `/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf`, alongside the other app Includes.
6. Reload Apache:
```bash
sudo apachectl configtest && sudo systemctl reload apache2
```
**Re-deploy** (after pushing changes to GitHub):
```bash
ssh user@optical-dev.oliver.solutions
cd /opt/mg-mcp
bash deploy/deploy.sh
```
Flags: `--no-pull`, `--no-build`, `--logs`.
Flags: `--no-pull` (skip git pull), `--no-build` (skip docker rebuild), `--logs` (tail container logs after).
---
## Verification
From a laptop after deploy:
From any laptop:
```bash
# 1. Health (no auth) — should return JSON
# 1. Health (no auth)
curl https://optical-dev.oliver.solutions/mg-mcp/api/health
# → {"status":"ok","service":"mg-mcp"}
# → {"status":"ok","service":"mg-mcp"}
# 2. MCP endpoint without auth — should 401
# 2. Auth gate
curl -i https://optical-dev.oliver.solutions/mg-mcp/mcp
# → HTTP/1.1 401, body {"error":"Missing Bearer token"}
# → 401 {"error":"Missing Bearer token"}
# 3. MCP initialize handshake (replace TOKEN)
TOKEN=...your MCP_BEARER_KEY...
curl -i -H "Authorization: Bearer $TOKEN" \
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 with a streaming JSON response listing server capabilities
# → 200, content-type: text/event-stream
# → event: message
# → data: {"jsonrpc":"2.0","id":1,"result":{...,"serverInfo":{"name":"mg-mcp",...}}}
```
## Configure a client (LibreChat)
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:
@ -124,13 +147,146 @@ Settings → MCP Connectors → Add:
| Transport | `Streamable HTTPS` |
| Authentication | `API Key` |
| Header Format | `Bearer` |
| API Key | the value of `MCP_BEARER_KEY` from the server's `.env` |
| API Key | the `MCP_BEARER_KEY` from `.env` |
Save, then prompt: *"Send an email to me@example.com with subject 'mg-mcp test' and body 'It works.'"*
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:
```json
{
"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`:
```json
{
"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:
```bash
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:
```bash
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:
```bash
claude mcp remove mailgun
```
---
## Local development
```bash
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:
```bash
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
- **`/mcp` returns 404** — the FastMCP SDK version may serve at a different sub-path. Check `docker compose logs app` for the actual route, or try `/` instead of `/mcp` in the client URL.
- **Streaming responses hang or truncate** — confirm `flushpackets=on` is in the rendered `apache-mg-mcp.conf` and that `mod_proxy_http` is loaded (`sudo a2enmod proxy proxy_http headers && sudo systemctl reload apache2`).
- **Health 200 locally, 502 publicly** — the Include line is missing from the vhost or Apache wasn't reloaded.
- **Mailgun 401 inside the tool** — the API key in `.env` is wrong; restart the container after fixing (`docker compose up -d`).
| 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 |