wa-mcp
by girik7
README.md
# wa-mcp
Self-hosted WhatsApp management over Model Context Protocol. One Docker stack per client. Baileys handles WhatsApp; SQLite stores messages; local disk stores originals; optional `whisper.cpp` transcribes voice notes on CPU.
Current scope: local development and validation. No VPS, tunnel, WAF, registry image, or public service has been deployed.
> Unofficial WhatsApp integration. Baileys is not affiliated with WhatsApp or Meta. Linked accounts can be rate-limited or banned. Use a test account first.
## Architecture
| Component | Purpose |
| ----------------------- | -------------------------------------------------------------------------------- |
| OpenWA v0.13.0 baseline | NestJS API, Baileys session lifecycle, QR onboarding, SQLite, dashboard |
| Streamable HTTP MCP | Stateless `POST /mcp` transport with 30 focused tools |
| SQLite | Sessions, messages, raw Baileys message references, media metadata, transcripts |
| Local media volume | Original documents/audio plus visual media downloaded on command |
| `whisper.cpp` | Optional internal-only CPU server using multilingual `ggml-medium.bin` |
| OAuth facade | Static client, PKCE, refresh tokens, callback allowlist; no external auth portal |
Persistent Docker volumes:
- `wa-data`: SQLite databases, Baileys auth state, saved media.
- `whisper-models`: verified `ggml-medium.bin` model.
## Requirements
- Docker Desktop with Linux containers, or Docker Engine + Compose on Linux.
- At least 4 GB RAM when transcription runs. Medium model requires roughly 2.1 GB working memory plus the API.
- About 2 GB free model storage, plus media/database capacity.
- A secondary WhatsApp account for first tests.
Validated development target: Docker 29.3.0, Compose 5.1.0, Linux amd64.
## Local setup
Copy the environment template:
```powershell
Copy-Item .env.example .env
```
Generate secrets in PowerShell:
```powershell
$clientBytes = [Security.Cryptography.RandomNumberGenerator]::GetBytes(16)
$secretBytes = [Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
[Convert]::ToHexString($clientBytes).ToLower()
[Convert]::ToHexString($secretBytes).ToLower()
```
Put the first value in `MCP_CLIENT_ID`; put the second in `MCP_CLIENT_SECRET`. Never commit `.env`.
Start without transcription:
```powershell
docker compose up -d --build
docker compose ps
```
Start with transcription:
```powershell
# Set VOICE_TRANSCRIPTION_ENABLED=true in .env first.
docker compose --profile transcription up -d --build
docker compose --profile transcription ps
```
First transcription start downloads the full multilingual medium model, verifies this SHA-256, then starts the server:
```text
6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208
```
The model and container images are not committed to Git. `Dockerfile.whisper` rebuilds the pinned
official source with `GGML_NATIVE=OFF`. This avoids illegal-instruction crashes when a client VPS CPU
does not expose instruction sets available on the upstream image's build machine.
## QR onboarding
1. Open `http://127.0.0.1:2785`.
2. Use `MCP_CLIENT_SECRET` as the bootstrap API key.
3. Create a session. Use a stable name such as `client-main`.
4. Start the session.
5. Open its QR view.
6. On the phone: WhatsApp → Linked devices → Link a device → scan.
7. Wait for session status `ready`.
PowerShell REST equivalent. `.env` does not populate the current PowerShell process, so enter the same `MCP_CLIENT_SECRET` when prompted:
```powershell
$base = 'http://127.0.0.1:2785'
$secureApiKey = Read-Host 'Enter MCP_CLIENT_SECRET' -AsSecureString
$apiKey = [Net.NetworkCredential]::new('', $secureApiKey).Password
$headers = @{ 'X-API-Key' = $apiKey }
$session = Invoke-RestMethod `
-Method Post `
-Uri "$base/api/sessions" `
-Headers $headers `
-ContentType 'application/json' `
-Body '{"name":"client-main"}'
$sessionId = $session.id
Invoke-RestMethod `
-Method Post `
-Uri "$base/api/sessions/$sessionId/start" `
-Headers $headers
```
Session creation returns `409` when that name already exists. List sessions and reuse the intended ID instead of creating a duplicate:
```powershell
Invoke-RestMethod -Uri "$base/api/sessions" -Headers $headers
```
Starting is asynchronous. Check the session first:
```powershell
$sessionState = Invoke-RestMethod `
-Uri "$base/api/sessions/$sessionId" `
-Headers $headers
$sessionState
```
If status is already `ready`, skip QR retrieval. Otherwise poll for the QR for up to two minutes. HTTP `400` means the engine has not produced it yet:
```powershell
$qr = $null
$qrDeadline = (Get-Date).AddMinutes(2)
do {
try {
$qr = Invoke-RestMethod `
-Uri "$base/api/sessions/$sessionId/qr" `
-Headers $headers
} catch {
$statusCode = if ($_.Exception.Response) {
[int]$_.Exception.Response.StatusCode
} else {
0
}
if ($statusCode -ne 400) { throw }
Start-Sleep -Seconds 2
}
} while (-not $qr -and (Get-Date) -lt $qrDeadline)
if (-not $qr) { throw 'QR was not ready within two minutes. Check API logs.' }
```
The response contains `qrCode` as a PNG data URL. Save and open it:
```powershell
$qrBytes = [Convert]::FromBase64String(
($qr.qrCode -replace '^data:image/png;base64,', '')
)
$qrPath = Join-Path (Get-Location) 'whatsapp-qr.png'
[IO.File]::WriteAllBytes($qrPath, $qrBytes)
Start-Process $qrPath
```
Scan it on the phone. Then wait for `ready`:
```powershell
$readyDeadline = (Get-Date).AddMinutes(2)
do {
$sessionState = Invoke-RestMethod `
-Uri "$base/api/sessions/$sessionId" `
-Headers $headers
if ($sessionState.status -in @('failed', 'action_required')) {
throw "WhatsApp session entered $($sessionState.status): $($sessionState.lastError)"
}
if ($sessionState.status -ne 'ready') { Start-Sleep -Seconds 2 }
} while ($sessionState.status -ne 'ready' -and (Get-Date) -lt $readyDeadline)
if ($sessionState.status -ne 'ready') {
throw "Session did not become ready. Last status: $($sessionState.status)"
}
$sessionState
Remove-Item -LiteralPath $qrPath
```
The QR file carries temporary pairing material. Delete it after a successful scan, as shown above.
### Phone-number pairing alternative
Start the session first. Send digits only: country code plus number, without `+`, spaces, or dashes.
```powershell
$pairingBody = @{ phoneNumber = '919876543210' } | ConvertTo-Json
$pairing = Invoke-RestMethod `
-Method Post `
-Uri "$base/api/sessions/$sessionId/pairing-code" `
-Headers $headers `
-ContentType 'application/json' `
-Body $pairingBody
$pairing.pairingCode
```
On the phone, choose WhatsApp's phone-number linking option and enter the returned eight-character code. Poll the session endpoint until status becomes `ready`.
Baileys auth persists under the `wa-data` volume. Ordinary container restarts do not require another scan.
## Session operations
These commands reuse `$base`, `$headers`, and `$sessionId` from QR onboarding.
List sessions and inspect one session:
```powershell
Invoke-RestMethod -Uri "$base/api/sessions" -Headers $headers
Invoke-RestMethod -Uri "$base/api/sessions/$sessionId" -Headers $headers
```
Stop the live engine without unlinking WhatsApp, then start it again:
```powershell
Invoke-RestMethod -Method Post -Uri "$base/api/sessions/$sessionId/stop" -Headers $headers
Invoke-RestMethod -Method Post -Uri "$base/api/sessions/$sessionId/start" -Headers $headers
```
Force-kill only a wedged engine. Stored WhatsApp credentials remain available for restart:
```powershell
Invoke-RestMethod -Method Post -Uri "$base/api/sessions/$sessionId/force-kill" -Headers $headers
```
Logout is destructive: it unlinks the companion device and removes stored WhatsApp credentials. Next start requires fresh pairing:
```powershell
Invoke-RestMethod -Method Post -Uri "$base/api/sessions/$sessionId/logout" -Headers $headers
```
Delete is destructive: it removes the session record and its local credentials. Confirm the exact UUID first:
```powershell
Invoke-RestMethod -Uri "$base/api/sessions/$sessionId" -Headers $headers
Invoke-RestMethod -Method Delete -Uri "$base/api/sessions/$sessionId" -Headers $headers
```
## MCP endpoint and auth
Endpoint:
```text
POST http://127.0.0.1:2785/mcp
```
Supported auth:
- OAuth authorization-code flow with PKCE and refresh tokens. Intended for ChatGPT/Claude remote connectors.
- HTTP Basic: username `MCP_CLIENT_ID`, password `MCP_CLIENT_SECRET`.
- Bearer or `X-API-Key`: raw API key, useful for Inspector and other MCP clients.
OAuth metadata:
```text
/.well-known/oauth-protected-resource
/.well-known/oauth-protected-resource/mcp
/.well-known/oauth-authorization-server
/authorize
/token
```
`MCP_REDIRECT_URIS` is a comma-separated exact allowlist. Unknown callback URLs fail closed. Claude's documented callback is included in `.env.example`; add the callback shown by ChatGPT during connector setup before deployment.
Basic-auth handshake example. Enter values from `.env`; do not paste them into committed scripts:
```powershell
$mcpClientId = Read-Host 'Enter MCP_CLIENT_ID'
$secureMcpClientSecret = Read-Host 'Enter MCP_CLIENT_SECRET' -AsSecureString
$mcpClientSecret = [Net.NetworkCredential]::new('', $secureMcpClientSecret).Password
$pair = "${mcpClientId}:${mcpClientSecret}"
$basic = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair))
$mcpHeaders = @{
Authorization = "Basic $basic"
Accept = 'application/json, text/event-stream'
}
$initializeBody = @{
jsonrpc = '2.0'
id = 1
method = 'initialize'
params = @{
protocolVersion = '2025-11-25'
capabilities = @{}
clientInfo = @{ name = 'manual-test'; version = '1.0.0' }
}
} | ConvertTo-Json -Depth 8
$initializeResponse = Invoke-WebRequest `
-UseBasicParsing `
-Method Post `
-Uri 'http://127.0.0.1:2785/mcp' `
-Headers $mcpHeaders `
-ContentType 'application/json' `
-Body $initializeBody
$initializeResponse.StatusCode
$initializeResponse.Content
```
Expected HTTP status: `200` or `202`. Streamable HTTP may return JSON or `text/event-stream`.
Confirm tool discovery:
```powershell
$toolsBody = @{
jsonrpc = '2.0'
id = 2
method = 'tools/list'
params = @{}
} | ConvertTo-Json -Depth 5
$toolsResponse = Invoke-WebRequest `
-UseBasicParsing `
-Method Post `
-Uri 'http://127.0.0.1:2785/mcp' `
-Headers $mcpHeaders `
-ContentType 'application/json' `
-Body $toolsBody
$toolsResponse.StatusCode
$toolsResponse.Content
```
ChatGPT custom MCP apps currently require a remote endpoint; they do not connect directly to localhost. Full write-capable custom MCP apps also depend on eligible ChatGPT workspace plans and admin settings. Claude remote connectors likewise connect from Anthropic infrastructure and support unauthenticated or OAuth servers—not arbitrary Basic auth. Client ID/secret fields in Claude are OAuth client credentials.
## MCP tools
Every tool has one job. Every input is schema-validated. All tools require `sessionId`.
### Chats and messages
- `list_chats`
- `get_chat`
- `list_unread_chats`
- `list_messages`
- `search_messages`
- `get_message_context`
### Contacts and groups
- `search_contacts`
- `get_contact`
- `get_group`
- `list_group_participants`
### Actions
- `send_text_message`
- `send_group_message_with_mentions`
- `reply_to_message`
- `forward_message`
- `react_to_message`
- `send_saved_media`
### Media
- `get_media_metadata`
- `list_saved_media`
- `download_media`
- `read_saved_media`
`read_saved_media` returns bounded base64 chunks, maximum 1 MiB per call. It never returns an unbounded file in one MCP response.
### Voice
- `get_voice_transcript`
- `get_transcription_status`
- `retry_voice_transcription`
### Metrics
- `count_inbound_messages`
- `count_outbound_messages`
- `count_active_chats`
- `list_top_active_chats`
- `list_unanswered_chats`
- `get_median_first_response_time`
- `get_p90_first_response_time`
No generic executor. No combined business-metrics tool.
Example tool call:
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "list_unanswered_chats",
"arguments": {
"sessionId": "SESSION_UUID",
"from": "2026-08-01T00:00:00.000Z",
"limit": 20
}
}
}
```
## Media policy
| Incoming type | Default behavior |
| ---------------- | ---------------------------------------- |
| Document | Auto-download original |
| Audio/voice note | Auto-download original |
| Image | Metadata only; `download_media` required |
| Video/GIF | Metadata only; `download_media` required |
| Sticker | Metadata only; `download_media` required |
Saved originals use deterministic per-session paths. Each file is written through a temporary file, atomically linked, and stored with byte size plus SHA-256. Duplicate message events converge on one database row and one file.
Visual on-demand downloads depend on the persisted Baileys raw-message store. Increase `BAILEYS_MESSAGE_STORE_LIMIT` if downloads must remain available far back in history.
## Voice transcription
Configuration:
```env
VOICE_TRANSCRIPTION_ENABLED=false
WHISPER_MODEL=medium
WHISPER_LANGUAGE=auto
WHISPER_THREADS=4
AUTO_DOWNLOAD_AUDIO=true
```
When disabled, voice media still saves and transcript status becomes `disabled`. No external transcription service runs. When enabled, the internal `whisper.cpp` server receives the saved original. No cloud fallback exists.
Stored transcript fields:
- text
- detected language
- model
- start/completion timestamps
- audio duration
- linked media and WhatsApp message IDs
- `pending`, `processing`, `success`, `error`, or `disabled` status
- bounded error text
Pending/processing jobs resume after restart. Failed jobs remain errors until `retry_voice_transcription` is called.
## Logs and health commands
Show container state and one-shot resource usage:
```powershell
docker compose ps
docker stats --no-stream
```
Check application readiness:
```powershell
$health = Invoke-RestMethod 'http://127.0.0.1:2785/api/health/ready'
$health
```
Read recent API logs or follow new entries:
```powershell
docker compose logs --tail 200 api
docker compose logs -f api
```
Read transcription startup, model-download, checksum, and server logs:
```powershell
docker compose --profile transcription logs --tail 200 whisper-model whisper
docker compose --profile transcription logs -f whisper
```
Stop log following with `Ctrl+C`; containers continue running.
Restart only the API, then recheck readiness and the persisted WhatsApp session:
```powershell
docker compose restart api
Invoke-RestMethod 'http://127.0.0.1:2785/api/health/ready'
Invoke-RestMethod -Uri "$base/api/sessions/$sessionId" -Headers $headers
```
Stop or start the complete stack without deleting volumes:
```powershell
docker compose stop
docker compose start
```
`docker compose down` removes containers and networks but keeps named volumes unless `--volumes` is supplied. Never add `--volumes` during ordinary maintenance.
## First-install verification
Run this sequence before connecting ChatGPT, Claude, or a production phone number:
1. Validate Compose interpolation:
```powershell
$env:MCP_CLIENT_ID = 'compose-check-client'
$env:MCP_CLIENT_SECRET = 'replace-with-a-temporary-32-byte-or-longer-secret'
docker compose config --quiet
Remove-Item Env:MCP_CLIENT_ID
Remove-Item Env:MCP_CLIENT_SECRET
```
2. Start the selected profile and confirm every required service is healthy:
```powershell
docker compose up -d --build
docker compose ps
Invoke-RestMethod 'http://127.0.0.1:2785/api/health/ready'
```
For transcription-enabled clients, use:
```powershell
docker compose --profile transcription up -d --build
docker compose --profile transcription ps
```
3. Create and pair a secondary WhatsApp account using the QR or phone-number commands above. Require session status `ready`.
4. Run MCP initialize and `tools/list`. Require HTTP `200` or `202` for initialize and HTTP `200` for tool discovery.
5. Send one inbound text, document, voice note, image, and short video from another account.
6. Confirm documents and audio save automatically. Confirm images and videos remain metadata-only until an explicit media-download tool call.
7. When transcription is enabled, wait for voice transcript status `success`; verify text, detected language, model, duration, and linked message/media IDs. When disabled, require status `disabled` and no Whisper service.
8. Review one read-only MCP result. Then test one write action against the secondary account after checking its exact chat and payload.
9. Restart the API and confirm session, messages, media, and transcript state persist:
```powershell
docker compose restart api
Invoke-RestMethod 'http://127.0.0.1:2785/api/health/ready'
Invoke-RestMethod -Uri "$base/api/sessions/$sessionId" -Headers $headers
```
10. Run the source, Inspector, and backup checks below. Do not treat deployment as complete while any check remains unverified.
## Tests
Local source checks:
```powershell
npm.cmd ci
npm.cmd run build
npm.cmd test -- --runInBand
```
Compose validation:
```powershell
$env:MCP_CLIENT_ID = 'compose-check-client'
$env:MCP_CLIENT_SECRET = 'replace-with-a-temporary-32-byte-or-longer-secret'
docker compose config --quiet
Remove-Item Env:MCP_CLIENT_ID
Remove-Item Env:MCP_CLIENT_SECRET
```
MCP Inspector:
```powershell
npx.cmd @modelcontextprotocol/inspector
```
Set the Inspector URL to `http://127.0.0.1:2785/mcp`; send `Authorization: Basic <base64(client:secret)>` or `X-API-Key: <secret>`.
GitHub Actions run Linux build, unit tests, Compose validation, API image build, portable Whisper image build, and a CPU server smoke test. CI uses the tiny model only for speed; production Compose always downloads and verifies multilingual medium.
## Backup and restore
Stop the stack before file-level backup:
```powershell
docker compose down
New-Item -ItemType Directory -Force backups | Out-Null
docker run --rm -v wa-mcp_wa-data:/source:ro -v "${PWD}/backups:/backup" alpine:3.22 tar -czf /backup/wa-data.tar.gz -C /source .
docker compose up -d
```
Model backup is optional; it can be re-downloaded and checksum-verified. `wa-data` is mandatory: it contains both SQLite files, WhatsApp pairing state, and media.
Restore is destructive. Verify the archive and target volume first:
```powershell
docker compose down
docker run --rm -v wa-mcp_wa-data:/target -v "${PWD}/backups:/backup:ro" alpine:3.22 sh -c "rm -rf /target/* && tar -xzf /backup/wa-data.tar.gz -C /target"
docker compose up -d
```
## VPS and Cloudflare Tunnel — documentation only
No deployment is performed by this repository setup.
Use the [Cloudflare Tunnel deployment runbook](./docs/cloudflare-tunnel.md) for the exact named-tunnel, DNS, environment, WAF, validation, and rollback sequence. The runbook uses a remotely managed tunnel and a host `cloudflared` service so the application remains reachable only at `127.0.0.1:2785` on the VPS.
Expected production shape:
1. Buy one Linux VPS per client.
2. Clone this repository and create a unique `.env`.
3. Bind the app to localhost only.
4. Follow the runbook to create one named tunnel per client and route one HTTPS hostname to `http://127.0.0.1:2785`.
5. Do not use Cloudflare's MCP portal.
6. Apply Cloudflare WAF source-IP allowlists for the AI provider ranges you use.
7. Keep OAuth enabled. WAF is defense in depth, not authentication.
8. Set `BASE_URL` to the public HTTPS origin and exact provider callbacks in `MCP_REDIRECT_URIS`.
9. Test metadata, OAuth, MCP initialization, read tools, then write tools.
Do not use a temporary quick tunnel for a client product. Do not expose port 2785 directly. Provider source ranges and connector requirements change; verify current official documentation during deployment.
## Security
- WhatsApp messages and downloaded files are untrusted input. They can contain prompt injection telling a model to call write tools. Require client-side confirmation for sends/forwards and review tool inputs.
- OAuth is single-client, single-installation infrastructure. Rotate `MCP_CLIENT_SECRET` to revoke every issued access/refresh token.
- `MCP_REDIRECT_URIS` uses exact matching. Never use wildcards.
- `/mcp` requires transport auth before tool discovery. Each tool call repeats API-key role and session-scope authorization.
- Keep `.env`, `data/`, media, SQLite, Baileys sessions, model files, and backups out of Git.
- WAF allowlists do not replace app auth. NAT/provider IP changes can also break legitimate connector traffic.
- Media and transcript content can contain personal or regulated data. Define retention, access, consent, and deletion policy per client.
- Backups contain credentials and message content. Encrypt them outside this stack.
Report vulnerabilities privately through the repository's GitHub Security Advisory feature. Do not open a public issue containing secrets or message data.
## Troubleshooting
### Compose refuses missing variables
Populate `MCP_CLIENT_ID` and `MCP_CLIENT_SECRET` in `.env`. Empty secrets fail closed.
### QR never appears
Check `docker compose logs api`. Confirm `ENGINE_TYPE=baileys`, outbound internet works, session is started, and no stale phone pairing is active.
### Visual media cannot download
The raw Baileys reference may have aged out of `BAILEYS_MESSAGE_STORE_LIMIT`, or WhatsApp may no longer serve the original. Raise the limit before long retention is required.
### Transcript stays `error`
Run `docker compose --profile transcription ps`. Confirm `VOICE_TRANSCRIPTION_ENABLED=true`, model initialization succeeded, and Whisper is healthy. Inspect `get_transcription_status`; then call `retry_voice_transcription` after fixing the cause.
### Medium model checksum fails
Delete only the invalid model volume after confirming its exact Compose project/volume name, then restart the transcription profile. Never bypass the checksum.
### Connector cannot reach localhost
Expected. ChatGPT and Claude remote connectors originate from their cloud infrastructure. Deployment requires a public HTTPS route or an officially supported secure tunnel.
## Public source and licensing
MIT license. See `LICENSE` and `NOTICE`.
A public MIT repository cannot contain a protected source file that prevents copying. MIT explicitly permits reuse, modification, and redistribution. `.gitignore` protects secrets/runtime data only. Proprietary product logic must live in a private repository/package under a different license; do not rely on obscurity inside this public repository.
## Attribution
- [OpenWA](https://github.com/rmyndharis/OpenWA), pinned baseline v0.13.0.
- [Baileys](https://github.com/WhiskeySockets/Baileys), pinned package `7.0.0-rc13`.
- [whisper.cpp](https://github.com/ggml-org/whisper.cpp), pinned OCI digest in Compose.
WhatsApp and Meta are trademarks of their respective owners. No affiliation or endorsement.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing