Skip to main content
Glama
ScottYates

email.notetoself

by ScottYates
README.md
# notetoself

A tiny MCP server that emails a note to yourself.

I set this up for personal use with the index01 ring.

- **Server name:** `notetoself`
- **Tool:** `send_note(message: str) -> str`
- **Subject:** `NTS:` + first 20 characters of the note
- **Body:** the full message, exactly as received
- **Transport:** legacy SSE. Clients `GET /mcp` to open the event stream and POST to `/mcp/posts/` for messages back.
- **Auth:** every request must carry `Authorization: Bearer <token>` matching an entry in `TOKENS_JSON`

## 1. Install

```bash
cd notetoself
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
```

## 2. Create a Gmail App Password

The server uses Gmail SMTP with an App Password (not your normal Gmail password).

1. Enable **2-Step Verification** on the Google account that will send the email
   (https://myaccount.google.com/security).
2. Open https://myaccount.google.com/apppasswords and create an App Password.
   - App name: anything, e.g. `notetoself`
   - Google shows you a 16-character password; copy it.
3. That's your `SMTP_PASS`.

## 3. Configure

Copy `.env.example` to `.env` and fill in:

```bash
cp .env.example .env
$EDITOR .env
```

Set at minimum:

| Variable        | Value                                              |
| --------------- | -------------------------------------------------- |
| `SMTP_USER`     | `beernutz@gmail.com` (the sending account)         |
| `SMTP_PASS`     | The 16-char App Password from step 2               |
| `TO_EMAIL`      | `beernutz@gmail.com` (where notes go; defaults to `SMTP_USER`) |
| `TOKENS_JSON`   | JSON array of allowed bearer tokens (see below)    |
| `ALLOWED_HOSTS` | Comma-separated list of allowed `Host` headers (see below) |

Generate a token:

```bash
python -c "import secrets; print(secrets.token_urlsafe(32))"
```

Example `TOKENS_JSON` with a single trusted token:

```
TOKENS_JSON=["PASTE_TOKEN_HERE"]
```

Multiple clients (each gets its own token):

```
TOKENS_JSON=["token-aaa","token-bbb"]
```

`ALLOWED_HOSTS` lists the `Host` header values the MCP transport security
layer will accept. Defaults to loopback only. **If you reach the server via
a reverse proxy on a public domain (e.g. `https://yatesframe.com/mcp`),
that domain must appear here** or every request gets HTTP 421
("Invalid Host header"). The corresponding `Origin` header is whitelisted
automatically: loopback hosts pair with `http://`, everything else with
`https://`.

```
ALLOWED_HOSTS=127.0.0.1,localhost,[::1],yatesframe.com
```

## 4. Run

```bash
python server.py
```

You should see:

```
notetoself ready on 0.0.0.0:3001 (tokens=1, transport=sse)
```

Smoke test from another shell:

```bash
curl http://localhost:3001/health
# {"status":"ok","server":"notetoself"}

# Open the SSE stream. The first event the server sends is the message
# post URL it expects clients to use. Use -N so curl streams the output
# instead of buffering until disconnect.
curl -N http://localhost:3001/mcp \
     -H "Authorization: Bearer PASTE_TOKEN_HERE" \
     -H "Accept: text/event-stream"
```

The first line you receive is the `endpoint` event telling you where to
POST messages (it'll be `/mcp/posts/?session_id=<hex>`). You won't get a
plain `tools/list` response from `curl` -- real MCP clients (Claude
Desktop, Cursor, etc.) handle the SSE protocol end-to-end.

The body `"remember to buy milk"` will arrive at `beernutz@gmail.com` with subject `NTS:remember to buy milk`.

## 5. Wire up an MCP client

### Claude Desktop

`~/.config/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "notetoself": {
      "type": "sse",
      "url": "http://localhost:3001/mcp",
      "headers": {
        "Authorization": "Bearer PASTE_TOKEN_HERE"
      }
    }
  }
}
```

Note the `type: "sse"`: this server uses the legacy SSE transport, not
streamable HTTP. The URL is `/mcp` (the SSE stream endpoint); the message
post URL is discovered automatically from the server's `endpoint` SSE
event.

If Claude Desktop runs on a different machine on the LAN, replace `localhost` with the server's LAN IP. Restart Claude Desktop. The `send_note` tool will show up in the tools list.

### Other clients

Any MCP client that supports streamable HTTP works. Point it at
`http://localhost:3001/mcp` with the single `Authorization` header above.

## File layout

```
notetoself/
|-- server.py            # MCP server entry + Starlette wiring
|-- auth.py              # ASGI middleware: bearer-token allowlist
|-- mailer.py            # SMTP send + subject/body rules
|-- config.py            # .env loading + validation
|-- requirements.txt
|-- .env.example         # copy to .env and edit
|-- tests/
|   `-- test_mailer_logic.py
|-- deploy/              # systemd unit + env file template + nginx config
|   |-- notetoself.service
|   |-- notetoself.env.example
|   `-- nginx.conf.example
`-- README.md
```

## 6. Run as a systemd service (Linux)

The repo ships a unit file in `deploy/notetoself.service` that runs the
server under a dedicated unprivileged user, restarts on failure, and reads
its config from `/opt/notetoself/.env` (in the app folder).

```bash
# 1. Pick an install root and clone/copy the repo there.
sudo install -d -o notetoself -g notetoself -m 0750 /opt/notetoself
sudo cp -r . /opt/notetoself/
sudo -u notetoself python3 -m venv /opt/notetoself/.venv
sudo -u notetoself /opt/notetoself/.venv/bin/pip install -r /opt/notetoself/requirements.txt

# 2. Install the env file in the app folder (mode 0600, owned by the service user).
sudo install -o notetoself -g notetoself -m 0644 \
     deploy/notetoself.env.example /opt/notetoself/.env
sudo chmod 0600 /opt/notetoself/.env
sudo -u notetoself $EDITOR /opt/notetoself/.env

# 3. Install + enable the unit.
sudo cp deploy/notetoself.service /etc/systemd/system/notetoself.service
sudo systemctl daemon-reload
sudo systemctl enable --now notetoself.service
sudo systemctl status notetoself.service
```

The unit binds to `0.0.0.0:3001` (configurable via the env file) and logs to
the journal (`journalctl -u notetoself.service -f`).

## 7. Reverse proxy with nginx

If you're exposing the server on a public domain (e.g.
`https://yatesframe.com/mcp`), put nginx in front of uvicorn. SSE is
notoriously easy to break with default nginx settings, so use the
config in `deploy/nginx.conf.example` rather than rolling your own.
The two non-obvious requirements:

- **`proxy_buffering off;`** — without it, nginx buffers the SSE stream
  and your MCP client times out before the first event arrives.
- **No URI in `proxy_pass`.** Use `proxy_pass http://127.0.0.1:3001;` and
  `location /mcp`, **not** `proxy_pass http://127.0.0.1:3001/mcp;`. The
  URI form rewrites `/mcp/posts/` to `/mcpposts/` at the backend and
  breaks the message path.

Also make sure `ALLOWED_HOSTS` in the env file includes the public
domain — otherwise transport security rejects every request with 421.

```nginx
# /etc/nginx/sites-enabled/notetoself (or included from your server block)
location /mcp {
    proxy_pass http://127.0.0.1:3001;
    proxy_redirect off;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header Authorization $http_authorization;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 1h;
    proxy_send_timeout 1h;
    chunked_transfer_encoding off;
}
```

## Security notes

- **The server binds to `0.0.0.0` by default, which exposes it to anything
  that can reach the host on `PORT`.** Put the box behind a firewall (drop
  inbound traffic to `3001` from untrusted networks), a reverse proxy with
  TLS, or restrict `HOST` to a loopback address in the env file if you only
  need local clients. `Authorization` headers carry the bearer token in
  cleartext without TLS.
- **MCP DNS-rebinding protection is on by default.** The `ALLOWED_HOSTS`
  list governs which `Host` header values are accepted; `Origin` headers
  are matched against the same list with a sensible scheme prefix. Add
  every public domain the server will be reached under, or clients will
  see HTTP 421 "Invalid Host header".
- Tokens are compared with `secrets.compare_digest` (constant-time).
- The server never logs message bodies, only `subject=...` + `body_chars=N`.
- `TOKENS_JSON` should be treated as a secret. Anyone with one of the
  listed tokens can email as you. Don't commit `.env` or the systemd env
  file.
- SMTP errors (wrong password, blocked sign-in, etc.) are returned to the
  calling LLM as plain text. Don't expose this server on a public network
  without thinking about that.