Skip to main content
Glama
README.md
# claude-notify

A remote MCP server exposing exactly one tool, `notify_me`, that sends a push
notification to your phone through [Pushover](https://pushover.net).

Write "notify me when the migration finishes" or "ping me if the test suite
fails" in a Claude Code prompt, and the session calls the tool when it gets
there. Sessions run for hours on several machines; this is how they reach you.

It is a *remote* server rather than a local one so that adding a machine is one
`claude mcp add` line with nothing to install, and so the Pushover credentials
live in exactly one place.

```
Claude Code (any machine)
  │  remote MCP over HTTPS, Authorization: Bearer <per-machine token>
  ▼
Cloudflare Worker ──► auth → rate limit → resolve label → truncate → POST ──► Pushover → iPhone
  │
  └─ KV: token hashes, rate counters, quota state
```

## The tool

| Param | Type | Required | Notes |
| --- | --- | --- | --- |
| `message` | string | yes | The notification body |
| `urgent` | boolean | no, default `false` | Pushover priority 1 — bypasses quiet hours |

There is deliberately no `source` or `machine` parameter. The label in the
title comes from the bearer token, resolved server-side, so a session cannot
mislabel itself and the model has one less argument to get wrong. Notifications
arrive titled `[vps-01] Claude Code`.

## First-time setup

You need a Cloudflare account, a Pushover account, and a Healthchecks.io check.

### 1. Pushover

1. Install Pushover on your phone and sign in. Your **user key** is on the
   [dashboard](https://pushover.net).
2. Register an application at <https://pushover.net/apps/build> to get an
   **API token**.

Since 1 May 2026 the monthly allowance is per *account*, not per application —
every app you register shares one pool of 10,000 messages a month.

### 2. Cloudflare

```bash
git clone <this repo> && cd claude-notify
npm install

cp wrangler.toml.example wrangler.toml
wrangler kv namespace create NOTIFY_KV
```

Put your `account_id` (from `wrangler whoami`) and the namespace id printed by
that command into `wrangler.toml`. That file is gitignored.

### 3. Healthchecks.io

Create a check at <https://healthchecks.io> with a period of 1 hour and a grace
of 20 minutes, and copy its ping URL.

**Treat that URL as a credential.** Anyone holding it can ping your dead-man's
switch and permanently suppress the alert that tells you the Worker died.

### 4. Secrets and deploy

```bash
wrangler secret put PUSHOVER_TOKEN     # the application API token
wrangler secret put PUSHOVER_USER      # your user key
wrangler secret put HEALTHCHECKS_URL   # https://hc-ping.com/<uuid>

wrangler deploy
```

Secrets are only ever set this way. They are never reachable by any client and
never appear in a file in this repo.

## Adding a machine

One token per machine, so a leak costs you one machine rather than all of them.

```bash
node scripts/token.mjs add vps-01
```

The token is printed **once** and cannot be recovered — only its SHA-256 hash is
stored. The command prints the registration line to run on that machine:

```bash
claude mcp add --transport http --scope user claude-notify \
  https://YOUR-WORKER.workers.dev/mcp \
  --header "Authorization: Bearer <the token printed by the CLI>"
```

Then check it:

```bash
claude mcp list        # claude-notify ... ✔ Connected
```

Managing machines:

```bash
node scripts/token.mjs list             # labels and status, never the tokens
node scripts/token.mjs revoke laptop    # that machine gets 401 on its next call
```

Add `--local` to any of these to work against the `wrangler dev` KV simulation
instead of production.

<details>
<summary>Equivalent raw wrangler commands</summary>

Only the hash is stored, so hash the token before writing it — a mistake here
produces a token that silently never authenticates.

```bash
TOKEN=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
HASH=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1)

wrangler kv key put "token:$HASH" '{"label":"ci-runner","enabled":true}' \
  --binding NOTIFY_KV --remote

echo "$TOKEN"   # shown once

# Revoke:
wrangler kv key delete "token:$HASH" --binding NOTIFY_KV --remote
```

</details>

## Using it

Just say so in the prompt:

> Run the full test suite and notify me if anything fails.

> This migration will take a while — ping me when it's done.

The session decides when to call the tool. It reports the real outcome back:
on success, how many Pushover messages remain this month; on failure, the
actual HTTP status and Pushover's own error text. It never retries and never
reports success it did not get, so if the pipe is broken the session says so.

## Health monitoring

A system cannot report its own death, so detection comes from outside.

**Hourly** the Worker reads KV and then pings Healthchecks.io. If the Worker
dies, Healthchecks notices the silence and emails you over a path that touches
neither Cloudflare nor Pushover. If KV is unreachable it pings `/fail` instead,
so a storage outage is reported rather than papered over.

**Daily at 08:00 Europe/Athens** it sends a real notification: `Pipe OK, N
remaining`. This is the only thing that exercises the entire chain, including
APNs and your iOS notification settings — which can break silently after an OS
update, in a way no server-side check can detect.

Cloudflare cron triggers are UTC-only, so the daily job is registered at both
05:00 and 06:00 UTC and the Worker checks the wall-clock hour in
`DAILY_PING_TZ`. One of the two is 08:00 in Athens whether or not daylight
saving is in effect, so exactly one firing per day gets through and the
notification lands at 08:00 local year-round.

## Rate limits and quota

The only power this endpoint has is making your phone buzz, so spam is the
threat and rate limiting is the control. Both limits are `[vars]` in
`wrangler.toml`:

- `RATE_LIMIT_PER_TOKEN` — default 60/hour per machine
- `RATE_LIMIT_GLOBAL` — default 200/hour across all machines, so one
  compromised machine cannot burn the whole Pushover quota

A breach comes back as a tool error naming the limit and when it resets, so the
calling session sees it.

Counters live in KV, which is eventually consistent and last-write-wins. Under a
concurrent burst some increments are lost and a few extra messages get through.
That is a deliberate trade: this is a spam guard on a doorbell, not an exact
quota.

Every Pushover response carries the account's remaining monthly allowance, which
is persisted. When it drops below `QUOTA_WARN_THRESHOLD` (default 500) a warning
is appended to the next notification — reserved *before* the message is
truncated, so a long message cannot push the warning off the end.

Pushover caps messages at 1024 characters and titles at 250. Both are truncated,
never rejected: a truncated notification beats a silent failure.

## Development

```bash
npm run typecheck

# Order matters. Local KV is a single SQLite database that only one Wrangler
# process may hold open, and each process carries its own workerd runtime — so
# create the token *before* starting the dev server, never alongside it.
# `token.mjs --local` refuses if it sees a dev server on port 8787.
node scripts/token.mjs add laptop --local
npm run dev                       # wrangler dev, with .dev.vars for secrets

NOTIFY_URL=http://localhost:8787/mcp \
NOTIFY_TOKEN=<the token printed above> \
  ./test/smoke.sh
```

Point `NOTIFY_URL` at `https://YOUR-WORKER.workers.dev/mcp` instead to smoke-test
the deployed Worker; a `--local` token will not authenticate against it, so use
one issued without `--local`.

Budget roughly **550 MB of RAM per `wrangler dev`** (a Node parent plus two
workerd processes). Two Wrangler processes at once need over a gigabyte, which is
enough to livelock a small VM that has no swap.

The smoke test drives the whole path — `tools/list`, a real `tools/call`, an
unknown token, and a browser-origin request — and reads its credentials from
the environment rather than from the file.

For local development put secrets in `.dev.vars` (gitignored):

```
PUSHOVER_TOKEN=...
PUSHOVER_USER=...
HEALTHCHECKS_URL=...
```

Cron jobs can be triggered by hand against `wrangler dev`:

```bash
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+*+*+*+*"    # Healthchecks ping
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+5,6+*+*+*"  # daily pipe test
```

## Protocol notes

MCP revision `2026-07-28` made the protocol stateless: no `initialize`
handshake, no sessions, no `Mcp-Session-Id`, no GET stream. Each request
declares its own protocol version and capabilities in a `_meta` envelope,
mirrored into `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers.

This server is **dual-era**: it serves both the stateless revision and 2025-era
clients that still open with `initialize`, on the same `/mcp` endpoint. Claude
Code's rollout of the stateless revision is still in progress across versions,
so serving only one era would strand clients on the other.

Requests carrying an `Origin` header are refused with 403. Nothing legitimate
calls this from a browser — Claude Code sends no `Origin` at all — and this is
the transport spec's required DNS-rebinding protection.

## Troubleshooting

**`✘ Failed to connect` in `claude mcp list`** — usually a bad or revoked token.
The server returns 401 with no detail, deliberately; check with
`node scripts/token.mjs list` and reissue if needed.

**Connected, but tool calls return `Unauthorized`** — there was a Claude Code
bug where the configured `--header` was attached on connect but dropped on tool
calls ([#50464](https://github.com/anthropics/claude-code/issues/50464), fixed
June 2026). If you see this, upgrade Claude Code first.

**Notifications stop arriving on the 1st of a month** — the Pushover account
quota is exhausted; it resets monthly. The daily pipe test reports the remaining
count, which is the warning you get before this happens.

**The daily test stops but the hourly check stays green** — the Worker and KV
are fine, so suspect the chain beyond them: Pushover credentials, or iOS
notification settings after an update.

## Security

- Tokens are stored only as SHA-256 hashes, keyed by the hash. Nothing compares
  secrets, so there is no timing-attack surface.
- Raw tokens are never logged. What reaches the request context is the hash.
- Unknown and disabled tokens are indistinguishable to the client: same status,
  same empty body.
- `PUSHOVER_TOKEN`, `PUSHOVER_USER` and `HEALTHCHECKS_URL` are Worker secrets
  and are never reachable by any client.
- If a credential leaks, **rotate it**. Rewriting git history is not sufficient.

`.gitignore` excludes `wrangler.toml` (it carries the account id and deployed
URL), `.dev.vars`, and `.env`. Every example in this file uses placeholders, and
every machine label is a generic one — `vps-01`, `laptop`, `ci-runner`. Real
labels live in KV, not in the repository.

## Non-goals

Deliberately not built, and not to be added: a second transport (Telegram,
ntfy); two-way messaging; Claude Code hooks integration; IP allowlisting or
Tailscale; scheduled or interval status pings; a local MCP server variant.

## License

MIT