Skip to main content
Glama
myi1

wa-web-mcp

by myi1
README.md
# wa-web-mcp

MCP server that lets Claude read and send WhatsApp for your team's
personal number, driving the **genuine WhatsApp Web client** (via WPPConnect)
behind a **code-enforced human-behavior middleware**.

> **Setup / template note:** this is a shareable copy with infrastructure
> specifics replaced by placeholders — `YOUR_TAILNET_IP`, `your-host`,
> `your-tailnet.ts.net`, unix user `youruser`, bundle id `com.example.wa-web-mcp`.
> Search-and-replace those (or drive them via `WA_WEB_URL` / env) for your own
> host before deploying. Config lives in `config.sample.json` (copy to
> `config.json`); nothing sensitive ships in the repo.


Built after two WhatsApp restrictions from an unofficial whatsmeow bridge. Two
principles (see [SPEC.md](SPEC.md)):

1. **Real client, not forged.** WPPConnect runs actual WhatsApp Web, so the
   on-sight fingerprint ban vector that killed the whatsmeow bridge is closed.
2. **Guards in code, not in the prompt.** Every limit is service-side
   middleware the model cannot talk past.

## Status

| Layer | State |
|---|---|
| Human-behavior middleware (all gates, reciprocity governor, warm-up) | **built + unit-tested (38 tests)** |
| WPPConnect client wrapper, outbound ritual executor | built, compiles; runtime-validated on the host |
| MCP HTTP server + tools + override header | built, compiles; runtime-validated on the host |
| Deploy (m2 LaunchDaemon) | artifacts ready in `deploy/`; needs SSH + one QR scan |

The safety-critical core is proven with deterministic tests. The WPPConnect +
MCP-transport glue compiles and follows the verified v2.2.1 / SDK 1.29 APIs,
but end-to-end behavior is validated on first deploy (needs a browser + a
linked session + a live MCP client — none reproducible in CI here).

## Layout

```
src/
  config.ts            defaults (SPEC §6) + JSON override loader; dryRun starts TRUE
  core/                clock (injectable), logger (decision log), tz helpers
  state/db.ts          SQLite: messages, new_contact_log (reciprocity), kv singletons
  middleware/
    ratelimit.ts       interval + hourly + usync (peek/commit)
    text.ts            normalize + token-set-Jaccard similarity (dedup)
    pipeline.ts        the 9 gates + reciprocity governor + warm-up ramp + health
  wa/
    jids.ts            @c.us / @g.us normalisation
    client.ts          WPPConnect wrapper: send/read/typing/presence + inbound/ack events
    outbound.ts        dry-run + serialised read→type→send ritual
  mcp/server.ts        tools + Streamable HTTP transport (bearer) + override header
  index.ts             wiring
```

## Run locally (dry-run, no send)

```bash
npm install          # local: set PUPPETEER_SKIP_DOWNLOAD=true (browser only needed on the host that links)
npm run build
npm test             # 38 unit tests
```

## Deploy (m2)

1. `rsync` this repo to `m2:~/wa-web-mcp/` (exclude node_modules/dist/store).
2. Put `deploy/token.txt` (a bearer, e.g. `openssl rand -hex 32`) and
   `config.json` (from `config.sample.json`) in place.
3. `ssh -t m2 "bash ~/wa-web-mcp/deploy/install-m2.sh"` — installs deps
   (incl. Chromium), builds, starts the LaunchDaemon.
4. `tail -f ~/wa-web-mcp/store/daemon.log`, scan the QR once.
5. Watch decisions in dry-run. When satisfied, set `config.json` `dryRun:false`
   and re-run the installer. Reads/warm replies first; new-contact outreach
   ramps from 2/day.

Point Claude Code at `http://YOUR_TAILNET_IP:8765/mcp` with the bearer.

## Safety controls

- `dryRun` (config): evaluate + log, never send. Default **true**.
- `halt_outbound` / `resume_outbound` tools: persisted kill switch, never bypassable.
- `X-Rate-Limit-Override` header: operator bypass of pacing gates (not kill switch, not dedup). LLM cannot set it.
- `get_send_health` tool: live posture (caps, reply rate, warm-up, cooldown).

## Sending a local file — from any Claude Code machine

Text sends are just the `send_message` tool. Sending a *local* file needs the
bytes to reach the daemon first, so `bin/wa-send-file` does upload (`POST
/media`) + `send_file` in one shot, entirely over the daemon's HTTP endpoint
(no scp/SSH). Every send still passes the full server-side pipeline.

```
wa-send-file <local-file> <recipient> [caption]
```

`recipient` is a chat id — E.164 digits, `<digits>@c.us`, `<id>@lid`, or a
`<id>@g.us` group. Resolve a contact **name** to its id with the `list_chats`
tool first. A bare number is resolved through the daemon's `resolveTarget`
path before send, so first-contact sends can work without a pre-existing chat
when WhatsApp's lookup returns a canonical id.

**Set up a new machine** (any Mac on the tailnet with Claude Code):

```
# 1. put the script on PATH
install -m 0755 bin/wa-send-file /usr/local/bin/wa-send-file
# 2. drop the bearer token where the script looks for it
mkdir -p ~/.config/wa-web && printf '%s' '<TOKEN>' > ~/.config/wa-web/token && chmod 600 ~/.config/wa-web/token
```

Token lookup order: `WA_WEB_TOKEN` env → `~/.config/wa-web/token` →
`./deploy/token.txt`. Base URL override: `WA_WEB_URL`. Operator bypass for one
call: `WA_WEB_OVERRIDE=1`. Exit codes: 0 sent, 3 held by a gate, 1 error.

The controls are the same everywhere because they live in the one daemon —
every machine is a thin client of it. Don't run a second daemon/linked device.

## Limitations / follow-ups

- `resolveTarget` falls back to the raw jid, with a warning log, when
  `checkNumberStatus` fails or reports no canonical id. A first-contact send
  can still target a stale jid if WhatsApp's lookup path is down.
- Staged uploads accumulate under `store/uploads/`; swept every 30 minutes (TTL 1h).
- `quietHours.outsideBehavior: 'defer'` is declared but not implemented —
  quiet-hours holds reject and rely on the queue to retry transient gates.