diff --git a/README.md b/README.md index 10fc2b0..35ab380 100644 --- a/README.md +++ b/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 client (LibreChat / Claude Desktop / Claude Code / etc.) + │ HTTPS + │ POST /mg-mcp/mcp Authorization: Bearer ▼ -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 `. 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 ``** 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** `` 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 ``, 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 |