wa-mcp
Self-hosted WhatsApp management over Model Context Protocol, exposing a streamable HTTP MCP endpoint with 30 tools for session/QR pairing, messaging, media storage, and optional on-CPU voice note transcription via whisper.cpp.
README
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: verifiedggml-medium.binmodel.
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:
Copy-Item .env.example .env
Generate secrets in 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:
docker compose up -d --build
docker compose ps
Start with transcription:
# 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:
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
- Open
http://127.0.0.1:2785. - Use
MCP_CLIENT_SECRETas the bootstrap API key. - Create a session. Use a stable name such as
client-main. - Start the session.
- Open its QR view.
- On the phone: WhatsApp → Linked devices → Link a device → scan.
- 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:
$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:
Invoke-RestMethod -Uri "$base/api/sessions" -Headers $headers
Starting is asynchronous. Check the session first:
$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:
$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:
$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:
$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.
$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:
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:
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:
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:
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:
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:
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, passwordMCP_CLIENT_SECRET. - Bearer or
X-API-Key: raw API key, useful for Inspector and other MCP clients.
OAuth metadata:
/.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:
$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:
$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_chatsget_chatlist_unread_chatslist_messagessearch_messagesget_message_context
Contacts and groups
search_contactsget_contactget_grouplist_group_participants
Actions
send_text_messagesend_group_message_with_mentionsreply_to_messageforward_messagereact_to_messagesend_saved_media
Media
get_media_metadatalist_saved_mediadownload_mediaread_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_transcriptget_transcription_statusretry_voice_transcription
Metrics
count_inbound_messagescount_outbound_messagescount_active_chatslist_top_active_chatslist_unanswered_chatsget_median_first_response_timeget_p90_first_response_time
No generic executor. No combined business-metrics tool.
Example tool call:
{
"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:
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, ordisabledstatus- 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:
docker compose ps
docker stats --no-stream
Check application readiness:
$health = Invoke-RestMethod 'http://127.0.0.1:2785/api/health/ready'
$health
Read recent API logs or follow new entries:
docker compose logs --tail 200 api
docker compose logs -f api
Read transcription startup, model-download, checksum, and server logs:
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:
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:
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:
-
Validate Compose interpolation:
$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 -
Start the selected profile and confirm every required service is healthy:
docker compose up -d --build docker compose ps Invoke-RestMethod 'http://127.0.0.1:2785/api/health/ready'For transcription-enabled clients, use:
docker compose --profile transcription up -d --build docker compose --profile transcription ps -
Create and pair a secondary WhatsApp account using the QR or phone-number commands above. Require session status
ready. -
Run MCP initialize and
tools/list. Require HTTP200or202for initialize and HTTP200for tool discovery. -
Send one inbound text, document, voice note, image, and short video from another account.
-
Confirm documents and audio save automatically. Confirm images and videos remain metadata-only until an explicit media-download tool call.
-
When transcription is enabled, wait for voice transcript status
success; verify text, detected language, model, duration, and linked message/media IDs. When disabled, require statusdisabledand no Whisper service. -
Review one read-only MCP result. Then test one write action against the secondary account after checking its exact chat and payload.
-
Restart the API and confirm session, messages, media, and transcript state persist:
docker compose restart api Invoke-RestMethod 'http://127.0.0.1:2785/api/health/ready' Invoke-RestMethod -Uri "$base/api/sessions/$sessionId" -Headers $headers -
Run the source, Inspector, and backup checks below. Do not treat deployment as complete while any check remains unverified.
Tests
Local source checks:
npm.cmd ci
npm.cmd run build
npm.cmd test -- --runInBand
Compose validation:
$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:
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:
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:
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 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:
- Buy one Linux VPS per client.
- Clone this repository and create a unique
.env. - Bind the app to localhost only.
- Follow the runbook to create one named tunnel per client and route one HTTPS hostname to
http://127.0.0.1:2785. - Do not use Cloudflare's MCP portal.
- Apply Cloudflare WAF source-IP allowlists for the AI provider ranges you use.
- Keep OAuth enabled. WAF is defense in depth, not authentication.
- Set
BASE_URLto the public HTTPS origin and exact provider callbacks inMCP_REDIRECT_URIS. - 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_SECRETto revoke every issued access/refresh token. MCP_REDIRECT_URISuses exact matching. Never use wildcards./mcprequires 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, pinned baseline v0.13.0.
- Baileys, pinned package
7.0.0-rc13. - whisper.cpp, pinned OCI digest in Compose.
WhatsApp and Meta are trademarks of their respective owners. No affiliation or endorsement.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。