ju-li

claude-whatsapp-mcp

Community ju-li
Updated

claude-whatsapp-mcp

A Nuxt app that serves two surfaces from one Nitro server:

  1. Web UI — users manage their Evolution API (WhatsApp) instances.
  2. MCP endpoint — Claude connects to /mcp as a custom connector.

PocketBase is the backend (users, sessions, per-user Evolution credentials).Evolution API, Postgres and Redis are dependencies you run, not code in this repo.

Status. Sign-up, WhatsApp pairing, the per-account dashboard, connectortoken provisioning and history import all work. Five MCP tools: connectionstatus, chat listing, message reading, message search (opt-in — see "Messagesearch") and text sending. Webhook event handling is not built;/api/webhook/evolution is a stub that logs and acks.

Layout

apps/web/                    Nuxt 4 + TypeScript. Has its own Dockerfile.
  app/                       pages, layouts, components (shadcn-vue)
  modules/                   local Nuxt modules (registers /mcp/:token)
  server/api/                auth, instances, tokens
  server/mcp/                MCP handler + tools
  server/utils/              PocketBase, auth, instances, Evolution client, redaction
services/pocketbase/         pinned PocketBase image + committed schema
docker-compose.dev.yml       services only — NOT Nuxt
.zed/                        tasks + language server config
.env.example                 every variable, documented

Package manager is pnpm ([email protected], pinned via packageManager). Do not use npm/yarn.

First run

cp .env.example .env          # then fill in the blanks — see comments in the file
pnpm install
pnpm services:up              # postgres, redis, evolution, pocketbase

The PocketBase superuser is created for you from NUXT_POCKETBASE_ADMIN_EMAILand NUXT_POCKETBASE_ADMIN_PASSWORD — the container upserts it on every boot, sothere is no manual step and changing the password is just editing .env andrestarting.

Then start Nuxt on the host (it is deliberately not in compose, so you keep HMR):

pnpm dev                      # http://localhost:3000

Admin UI: http://localhost:8090/_/ · Evolution: http://localhost:8080 · Nuxt: http://localhost:3000

There is no predev hook — bring the services up yourself.

Scripts

pnpm dev Nuxt dev server on the host
pnpm build / pnpm preview production build / serve it
pnpm typecheck nuxt typecheck across app + server
pnpm services:up / :down / :logs / :ps the compose stack

Networking

Traffic crosses the host/container boundary in both directions.

From To Address
Nuxt (host) Evolution http://localhost:8080
Nuxt (host) PocketBase http://localhost:8090
Evolution (container) Nuxt webhook http://host.docker.internal:3000/api/webhook/evolution

host.docker.internal is not resolvable in Linux containers by default, so theevolution service declares extra_hosts: ["host.docker.internal:host-gateway"].

The webhook URL comes from WEBHOOK_URL / NUXT_WEBHOOK_URL, so dev and proddiffer by configuration only — no code change.

pnpm dev runs nuxt dev --host 0.0.0.0. It has to: bound to localhost, Nuxtis unreachable from the Evolution container.

Binding 0.0.0.0 means the socket listens on every interface, including yourLAN one. Whether that is actually reachable from the LAN depends on yourfirewall — with ufw enabled and no blanket allow 3000, inbound LAN traffic isstill dropped and only the narrowly-scoped rule below gets through. With nofirewall, port 3000 is open to your network. Drop --host 0.0.0.0 fromapps/web/package.json if you are on an untrusted network and can live withoutinbound webhooks.

Linux firewall

On a Linux host with ufw enabled, the webhook silently times out until youallow it. This is not a Docker quirk — it is ordinary inbound filtering:

  • The container has its own network namespace, so it reaches the host over aroutable host IP (host.docker.internal172.17.0.1, the docker0address), not over loopback.
  • That packet arrives on a real host interface destined for a host-ownedaddress, so it enters the INPUT chain — the same path as a packet off yourLAN. ufw's DEFAULT_INPUT_POLICY is DROP, so it is dropped (silently, hencethe timeout rather than a refused connection).
  • The reverse direction works because published container ports are DNAT'd andtravel the OUTPUT/FORWARD path, never INPUT. Docker writes NAT and FORWARDrules only — it never opens INPUT, so container → host is governed by ufwnormally.
  • ping from the container succeeds regardless: /etc/ufw/before.rules acceptsICMP echo ahead of the default deny. Reachable-by-ping but not by TCP is thesignature of this problem.

The compose network's subnet is pinned to 172.31.250.0/24 so one stable rulecovers it:

sudo ufw allow from 172.31.250.0/24 to 172.17.0.1 port 3000 proto tcp comment 'evolution -> nuxt webhook'

Verify the round-trip:

docker compose -f docker-compose.dev.yml exec evolution \
  wget -T 5 -qO- --post-data='{"event":"ping"}' \
    --header='content-type: application/json' \
    http://host.docker.internal:3000/api/webhook/evolution

{"ok":true} means the path is open; a timeout means the rule is missing or thesubnet does not match. Everything else in the stack works without this rule —only inbound webhooks need it.

Auth

Two paths on the same app, deliberately kept apart. An MCP tool never fallsback to the browser session.

Web UI MCP
Credential PocketBase session cookie Authorization: Bearer <token>, or /mcp/<token>
Resolved by server/middleware/session.tsserver/utils/session.ts server/mcp/index.tsserver/utils/mcp-auth.ts
Context key event.context.user event.context.mcpAuth
On failure 401 JSON 401 + WWW-Authenticate — never 200

server/middleware/session.ts returns early on /mcp, so cookies are never evenparsed there. useEvolutionClient() (used by tools) reads event.context.mcpAuthand has no code path to the session user.

The MCP token is minted by this app — it is not Evolution's apikey. Only itsSHA-256 hash is stored, in the superuser-only mcp_tokens collection, alongsidelast_used_at and expires_at. It resolves to one row in instances, whichholds that account's Evolution token server-side.

Evolution's global key never reaches a user record. It is used only to createand delete instances (server/utils/instances.ts). Every other call uses theper-instance token Evolution issues at create time, which Evolution itself scopesto that one instance. There is deliberately no fallback from a missingper-instance key to the global one — that would hand any token holder access toevery user's account.

A PocketBase outage answers 503, not 401 — a 401 would tell a client itsvalid token had been revoked and invite it to throw the token away.

Tokens never reach logs or error bodies: server/plugins/redact-mcp.ts scrubs/mcp/<token> to /mcp/[redacted] at the source (event.node.req.originalUrl,which is what Nitro's error handler reads). Route any error reporter you addthrough redactPath / redactHeaders in server/utils/redact.ts.

Using it

  1. Sign up at http://localhost:3000.
  2. Name the account and continue — the app provisions an Evolution instance foryou and shows a QR code.
  3. Scan it: WhatsApp → Settings → Linked devices → Link a device.
  4. On the account page, create a connector token and copy the URL it shows.
  5. Add that URL to Claude as a custom connector.

One WhatsApp account per connector. A token is bound to the account it wascreated on, so the tools take no account argument and Claude cannot address thewrong number. Connect several accounts and give each its own token.

Scoping a token

A token can be narrowed on two independent axes, both edited from the account page:

  • Actions — all tools, or a chosen few. Enforced by refusing to register theothers for that request, so a tool outside scope is not merely hidden from thetool list: calling it fails.
  • Chats — all conversations, or an allowlist. list-chats returns onlyallowed conversations; read-messages and send-text-message refuse anythingelse, naming the chat so the assistant can explain why. search-messages doesboth: asked for a chat outside scope it refuses by name, while an unrestrictedsearch is narrowed to the allowed chats — out-of-scope messages are excluded bythe query itself, not filtered out after being read.

The chat picker lists conversations Evolution has recorded — the history importedat pairing, plus everything since. An account paired before full-history sync wasswitched on shows only the latter until it is re-imported. Either way a numberthat has never messaged you can be added directly; it is checked against WhatsAppbefore it is accepted.

Scope is read fresh on every request, so editing a token's scope takes effectimmediately and does not reissue it. The connector already configured in Claudekeeps working; only what it can reach changes.

A scoped send refuses when it cannot verify the recipient — if the account isdisconnected, the message is not sent rather than sent unchecked.

The token is shown exactly once — only its SHA-256 hash is stored. Lost tokensare replaced, not recovered. Revoking one takes effect immediately, and revokingstays available while an account is disconnected.

Inspect the endpoint by hand with:

pnpm dlx @modelcontextprotocol/inspector

Point it at http://localhost:3000/mcp/<token>, or at http://localhost:3000/mcpwith an Authorization: Bearer <token> header.

Message search

search-messages is off unless you configure it, and it is the one featurethat does not go through the Evolution API.

Evolution 2.3.7 cannot search message content. POST /chat/findMessages accepts awhere.message — its request schema even documents the field — and then neverreads it, so a content search comes back as an unfiltered page that looks like aresult set. The only filters it honours are id, source, messageType, amessageTimestamp range, and key.{id,remoteJid,fromMe,participant}. Searchingtherefore means reading Evolution's Postgres directly, viaNUXT_EVOLUTION_DATABASE_URL. Leave it empty and the tool is not registered atall — clients never see it — rather than failing when called.

Give it a role that can do nothing else. Every other credential in this app isscoped to a single account; this connection can reach every user's messages inevery instance. The app never writes and never runs DDL, so:

CREATE ROLE wamcp_search LOGIN PASSWORD 'change-me';
GRANT CONNECT ON DATABASE evolution TO wamcp_search;
GRANT USAGE ON SCHEMA public TO wamcp_search;
GRANT SELECT ON "Message" TO wamcp_search;
NUXT_EVOLUTION_DATABASE_URL=postgres://wamcp_search:change-me@localhost:5432/evolution

In development you can point it at POSTGRES_USER instead; in production do not.

Evolution ships @@index([instanceId]) and nothing else — no index onmessageTimestamp, none on the key JSONB — so search is a sequential scanwithin one account. History import makes that corpus much larger than it wouldotherwise be: a number with years of conversations arrives all at once atpairing, rather than accumulating. The queries carry a 10-secondstatement_timeout so a slow scan surfaces as an error instead of a hung MCPcall. Once a scan starts timing out, add (as a Postgres superuser, not as theapp):

CREATE INDEX CONCURRENTLY IF NOT EXISTS message_instance_ts_idx
  ON "Message" ("instanceId", "messageTimestamp" DESC);

Two caveats worth knowing before you go looking for a bug:

  • Search covers whatever Evolution holds: the history imported at pairing pluseverything since. An account paired before full-history sync was turned on hasonly what arrived after — see "Importing existing history".
  • The scope editor lists search-messages whether or not the database URL isset, because it reads the tool registry rather than the live per-request toollist. Granting it to a token is harmless while search is unconfigured.

On Railway, Evolution's Postgres is its own service — use its private URL, andnote the port there is whatever that service actually listens on (see "Pin theports").

Adding tools

Drop a file in apps/web/server/mcp/tools/ — it is discovered automatically.Give every tool a title and the applicable readOnlyHint / destructiveHint;see get-connection-status.ts (read) and send-text-message.ts (write) for thepattern.

Also give it an explicit name and an enabled guard so it participates intoken scoping, and enforce chat scope in the handler if it touches aconversation. Existing scoped tokens will not be granted the new tool — they listthe tools they were given, so new tools are denied by default.

Tool handlers get the MCP SDK's RequestHandlerExtra, not an H3 event, socredentials are reached through useEvolutionClient()useEvent(). That is whynitro.experimental.asyncContext is enabled in nuxt.config.ts — do not turn it off.

PocketBase schema

users holds accounts. instances holds one row per connected WhatsApp number,including that instance's Evolution token as a hidden field. mcp_tokens holdshashed connector tokens, each bound to one instance and cascade-deleted with it.All three collections are superuser-only — the browser never talks to PocketBase,so the session cookie is httpOnly and every read goes through a Nuxt route.

services/pocketbase/pb_migrations/ is committed and is the source of truth.pb_migrations/ and pb_hooks/ are bind-mounted, so schema changes you make inthe admin UI are written straight back into the working tree — commit them.

pb_data/ is gitignored runtime state. The container runs as root, so on Linuxthe directory ends up root-owned; remove it through a container:

docker compose -f docker-compose.dev.yml stop pocketbase
docker run --rm -v "$PWD/services/pocketbase:/x" alpine:3.22.5 rm -rf /x/pb_data
docker compose -f docker-compose.dev.yml up -d pocketbase

That resets everything, including the superuser, and re-applies every migration.

Importing existing history

WhatsApp hands over past conversations once, in a burst it pushes while adevice is being linked. There is no endpoint — here or upstream — that fetcheshistory afterwards. Evolution exposes /chat/findMessages, but that readsEvolution's own Postgres, not WhatsApp.

Three things have to line up, and two of them have to be true before the QR isscanned:

Where Effect if wrong
DATABASE_SAVE_DATA_HISTORIC=true evolution service env Evolution receives the burst and drops it
syncFullHistory: true sent at POST /instance/create, in server/utils/instances.ts Only recent messages arrive, and groups are skipped
A fresh device link scanning the QR Reconnecting an existing session sends no history

New accounts get all three automatically. An account paired before this wasturned on cannot be backfilled in place — Import full history on the accountpage sets the flag, signs the device out and puts the QR back up; the importrides in on the re-scan. Nothing already stored is lost: Evolution skips messageswhose key.id it already has, so re-importing merges rather than duplicates.

Watch it land with pnpm services:logs while scanning:

recv 412 chats, 1180 contacts, 39204 msgs (is latest: false, progress: 34%), type: 2

type: 2 is a full sync, type: 3 is the recent-only one. Anything but 2 meanssyncFullHistory never reached the socket.

Caveats worth knowing before you rely on it:

  • Media is not downloaded. History gives message records; the bytes still needgetBase64FromMediaMessage per message.
  • Depth is whatever the phone volunteers. syncFullHistory asks foreverything; it is not a guarantee of everything.
  • Groups come too. syncFullHistory overrides Evolution's group filter, sogroup chats appear in list-chats and in the token scope picker.
  • Reads get slower as it grows. Evolution indexes its Message table oninstanceId only; remoteJid lives inside a JSONB column with no index.

⚠️ WhatsApp pairing burns a real phone number

Scanning the QR binds a real WhatsApp account to an instance. WhatsApp rate-limitsand can ban numbers that pair and unpair repeatedly, or that send unsolicitedmessages from a freshly-paired session. Use a spare SIM, not your primarynumber, and keep test traffic to conversations you control.

⚠️ docker compose down -v forces a QR re-scan

-v deletes the evolution_instances volume, which holds every paired session.Every instance has to be re-paired by scanning a new QR code — see the warningabove about what that costs. For routine restarts use:

docker compose -f docker-compose.dev.yml down     # no -v

Deploying to Railway

Five services. Two are built from this repo, three you provision.

Service Source Target port Volume
Postgres Railway template managed
Redis Railway template managed
evolution image evoapicloud/evolution-api:v2.3.7 8080 /evolution/instances
pocketbase this repo, root directory services/pocketbase 8090 /pb_data
web this repo, root directory /, Dockerfile path apps/web/Dockerfile 3000

The web service builds from the repo root, not apps/web — the lockfile andworkspace manifest live there. Set its Dockerfile path rather than its rootdirectory.

The volumes are not optional. Without /evolution/instances, every deployunpairs every WhatsApp account and forces a fresh QR scan on each one. Without/pb_data, you lose all users, connected accounts and tokens.

Attach them in each service's settings. Railway rejects a VOLUME instructionin a Dockerfile — "docker VOLUME at Line N is not supported, use RailwayVolumes" — so neither image declares one, and nothing warns you at deploytime if you forget.

Pin the ports

Railway injects PORT (8080 by default) into every service, and both imagesfollow it. So a service listens on Railway's port, not on the default in itsDockerfile — point another service at the wrong one and you get ECONNREFUSEDfrom a hostname that resolves perfectly well.

Set these explicitly so nothing depends on Railway's default:

Service Variable
pocketbase PORT=8090
evolution SERVER_PORT=8080 (Evolution reads this, not PORT)
web nothing — it is the public service, let Railway assign it

Then the internal URLs below match, and each service's target port matches thetable above.

Environment

Use Railway's variable references (${{Service.VAR}}) so a rotated secretpropagates instead of drifting out of sync.

evolution

SERVER_PORT=8080
SERVER_URL=https://<evolution-domain>
AUTHENTICATION_API_KEY=<openssl rand -hex 16>
DATABASE_PROVIDER=postgresql
DATABASE_CONNECTION_URI=${{Postgres.DATABASE_URL}}?schema=public
DATABASE_CONNECTION_CLIENT_NAME=evolution_exchange
DATABASE_SAVE_DATA_INSTANCE=true
DATABASE_SAVE_DATA_NEW_MESSAGE=true
DATABASE_SAVE_MESSAGE_UPDATE=true
DATABASE_SAVE_DATA_CONTACTS=true
DATABASE_SAVE_DATA_CHATS=true
DATABASE_SAVE_DATA_HISTORIC=true
CACHE_REDIS_ENABLED=true
CACHE_REDIS_URI=${{Redis.REDIS_URL}}/6
CACHE_REDIS_PREFIX_KEY=evolution
CACHE_LOCAL_ENABLED=false
WEBHOOK_GLOBAL_ENABLED=true
WEBHOOK_GLOBAL_URL=https://<web-domain>/api/webhook/evolution
WEBHOOK_GLOBAL_WEBHOOK_BY_EVENTS=false
WEBHOOK_EVENTS_CONNECTION_UPDATE=true
WEBHOOK_EVENTS_MESSAGES_UPSERT=true
TELEMETRY_ENABLED=false

The DATABASE_SAVE_DATA_* flags are what populate the dashboard counts and makelist-chats and read-messages return anything. Turn them off and those toolsgo quiet.

DATABASE_SAVE_DATA_HISTORIC is the odd one out: the others cover live traffic,that one covers the history WhatsApp hands over once, when a number is paired.Evolution checks it in the messaging-history.set handler and silently drops thewhole payload if it is false — with no way to ask for the history again short ofdisconnecting and re-scanning the QR. See "Importing existing history" below.

web — internal addresses for the backends, public URLs for anything a user sees:

NUXT_POCKETBASE_URL=http://pocketbase.railway.internal:8090   # matches PORT=8090 above
NUXT_POCKETBASE_ADMIN_EMAIL=<you>
NUXT_POCKETBASE_ADMIN_PASSWORD=<generate>
NUXT_EVOLUTION_URL=http://evolution.railway.internal:8080    # matches SERVER_PORT above
NUXT_EVOLUTION_ADMIN_KEY=${{evolution.AUTHENTICATION_API_KEY}}
NUXT_WEBHOOK_URL=https://<web-domain>/api/webhook/evolution
NUXT_WEBHOOK_SECRET=<openssl rand -hex 32>
NUXT_PUBLIC_APP_URL=https://<web-domain>

NUXT_PUBLIC_APP_URL is what connector URLs are built from. Get it wrong andevery token you hand out points at the wrong host.

NUXT_EVOLUTION_ADMIN_KEY is the most sensitive value in the deployment: it cancreate, read and delete every user's WhatsApp connection.

First run

Nothing to do. Set NUXT_POCKETBASE_ADMIN_EMAIL andNUXT_POCKETBASE_ADMIN_PASSWORD on the pocketbase service as well as the webservice — to the same values — and its entrypoint upserts the superuser on everyboot. The schema needs no action either; pb_migrations/ is baked into the imageand applied at startup.

Both variables must match across the two services: the web server signs in withthem to read hidden fields and the admin-only collections. A Railway variablereference (${{pocketbase.NUXT_POCKETBASE_ADMIN_EMAIL}}) keeps them in step.

Because the upsert runs every boot, rotating the password is editing the variableon both services and redeploying. Look for [entrypoint] superuser ready: in thepocketbase logs to confirm.

That account can read every user's Evolution API key. Give it a long password —PocketBase's CLI will accept a short one without complaint, though the entrypointwarns.

Then open the web service's domain and sign up.

Notes

  • Do not ship a .env. It is gitignored, and Railway variables replace it.
  • host.docker.internal does not exist here. The webhook uses the publicHTTPS URL instead, which is the only thing that differs between dev and prod —and it differs by configuration, not code.
  • Both containers bind ::, which accepts IPv4 and IPv6. Railway environmentscreated before 16 October 2025 route the private network over IPv6 only, wherebinding 0.0.0.0 is unreachable internally.
  • docker-compose.dev.yml is for local development only. Nothing in it is usedby Railway.

Editor

.zed/ ships tasks (services: up, web: dev, mcp: inspector, …) and languageserver settings. Install the Vue extension in Zed for vue-language-server;TypeScript uses the bundled vtsls, pointed at apps/web/node_modules/typescriptbecause pnpm does not hoist.

MCP Server · Populars

MCP Server · New

    ocm-mcp-server

    🛡️ ocm-mcp-server

    An MCP server that lets AI agents operate a multi-cluster Kubernetes fleet through an Open Cluster Management hub, with policy, approval, and audit between the model and your clusters.

    Community ocm-mcp-server
    M4F-S

    Gomaa 🧠

    Gomaa — Autonomous Agent Memory OS. Persistent memory system for AI agents with Obsidian vault integration, hybrid RRF search, knowledge graphs, security gates, and MCP server.

    Community M4F-S
    chatmcp

    3802

    directory for Awesome MCP Servers

    Community chatmcp
    Morningstar202604

    AgentSeed

    Anti-hallucination gate for AI coding agents — 8 MCP tools catch invented APIs (17 languages), fake "all tests pass" claims, and slopsquatting packages before they ship. Zero-dependency Agent Plugins 1.0.0 plugin (Skill + MCP server + CLI + CI gate) for Claude Code, Cursor, VS Code, Copilot.

    Community Morningstar202604
    skarn-security

    Skarn guard: agent plugins

    Skarn plugins for Claude Code, Codex CLI, Gemini CLI, Grok Build, and Antigravity: audit skills, guard hooks, and MCP declarations that find leaked secrets and credentials in AI coding sessions, locally and redacted.

    Community skarn-security