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:
parent
5e1a88b53c
commit
569c7946a7
1 changed files with 233 additions and 77 deletions
310
README.md
310
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue