claude-notify
by slamprou
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
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues