wazap-mcp 0.9.8 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  ```
9
9
 
10
10
  **WhatsApp for your AI agent.** An MCP server that puts your WhatsApp account —
11
- chats, messages, media, contacts, groups — behind 23 tools any MCP client can
11
+ chats, messages, media, contacts, groups — behind 24 tools any MCP client can
12
12
  call. Pairing-code login, no browser, no phone-number reseller, ~20 MB of RAM.
13
13
 
14
14
  Built on [Baileys](https://github.com/WhiskeySockets/Baileys), which speaks the
@@ -82,7 +82,7 @@ it would write.
82
82
  | `gemini` | `~/.gemini/settings.json` |
83
83
  | `windsurf` | `~/.codeium/windsurf/mcp_config.json` |
84
84
  | `opencode` | `mcp.whatsapp` in `~/.config/opencode/opencode.json` |
85
- | anything remote | client's MCP URL field: `https://your-host/mcp` with header `Authorization: Bearer <token>` (see [Self-host](#self-host)) |
85
+ | anything remote | client's MCP URL field: `https://your-host/mcp` with header `Authorization: Bearer <token>`, or just the URL once [OAuth](#hosted-agents-oauth) is on (see [Self-host](#self-host)) |
86
86
 
87
87
  ### Other MCP clients
88
88
 
@@ -363,6 +363,7 @@ created `0700` with credentials written `0600`:
363
363
  store.json chat-list snapshot
364
364
  server.lock pid of the running server
365
365
  daemon.json loopback endpoint a second wazap bridges to
366
+ oauth.json registered agents and hashed OAuth grants, when OAuth is on
366
367
  .env optional settings, see .env.example
367
368
  ```
368
369
 
@@ -413,7 +414,8 @@ npx wazap-mcp serve --http --host 0.0.0.0 --port 8766
413
414
  Streamable HTTP at `/mcp`, with a health check at `/healthz`. Two bearer tokens:
414
415
  the read token gets the read tools, the write token also unlocks the write
415
416
  tools, so a leaked read token can never message anyone. wazap refuses to bind a
416
- non-loopback address without a read token.
417
+ non-loopback address without a read token. Agents that cannot carry a header
418
+ sign in with [OAuth](#hosted-agents-oauth) instead.
417
419
 
418
420
  ## Self-host
419
421
 
@@ -448,9 +450,63 @@ curl -s http://127.0.0.1:8766/healthz
448
450
 
449
451
  The container publishes `8766` on loopback only; add the same TLS proxy in front. Upgrading is `git pull && docker compose up -d --build`; the volume keeps the session.
450
452
 
453
+ ### From a machine without a public address
454
+
455
+ A laptop or a box behind NAT can still serve hosted agents through a tunnel, with no port opened and TLS done at the edge. With [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) and a domain on Cloudflare:
456
+
457
+ ```bash
458
+ cloudflared tunnel login
459
+ cloudflared tunnel create wazap
460
+ cloudflared tunnel route dns wazap wazap.example.com
461
+ cloudflared tunnel run --url http://127.0.0.1:8766 wazap
462
+ ```
463
+
464
+ wazap keeps binding loopback; only the tunnel reaches it. Set `WAZAP_PUBLIC_URL=https://wazap.example.com` for OAuth and keep `cloudflared` running the way you keep wazap running (a systemd unit, a launchd agent). Tailscale Funnel or ngrok work the same way: whatever ends at `https://your-host` with `/mcp` behind it.
465
+
451
466
  ### Which clients can reach it
452
467
 
453
- Claude Code, Claude Desktop, Cursor, Codex, VS Code and any client with an "MCP URL + header" field connect with the bearer token. claude.ai Connectors require OAuth rather than a static token, so they cannot use a self-hosted wazap yet. Keep the read token in clients that only need to read; hand out the write token deliberately.
468
+ Claude Code, Claude Desktop, Cursor, Codex, VS Code, Poke and any client with an "MCP URL + header" field connect with the bearer token. Keep the read token in clients that only need to read; hand out the write token deliberately.
469
+
470
+ claude.ai Connectors, ChatGPT and some hosted agents will not take a static header. They want OAuth, which is the next section.
471
+
472
+ ### Hosted agents (OAuth)
473
+
474
+ Two more lines in the same `.env` turn wazap into its own OAuth 2.1 server:
475
+
476
+ ```bash
477
+ WAZAP_PUBLIC_URL=https://wazap.example.com
478
+ WAZAP_OAUTH_PASSWORD=$(openssl rand -base64 18)
479
+ ```
480
+
481
+ Then give an agent nothing but `https://wazap.example.com/mcp`. It finds the
482
+ authorization server at `/.well-known/oauth-protected-resource/mcp`, registers
483
+ itself (RFC 7591, so there is no client id to paste anywhere), and sends you to
484
+ a page on your own host that asks two things: the password above, and whether
485
+ this agent may only read or also send. A refresh token keeps the agent signed
486
+ in until you revoke it; access tokens rotate every 24 hours on their own.
487
+
488
+ Tested against the flow claude.ai, ChatGPT and Poke use: S256 PKCE, public
489
+ clients, `/token` with refresh, `/revoke`. The bearer tokens keep working next
490
+ to it, so a laptop client on a header and a hosted agent on OAuth share one
491
+ server.
492
+
493
+ What to know before exposing it:
494
+
495
+ - `WAZAP_PUBLIC_URL` must be `https` and a bare origin, no path: the
496
+ endpoints live at its root. The password travels to it.
497
+ - The password is the whole identity layer. Use a long one. A consent page
498
+ takes three wrong guesses and is gone; five from one address lock that
499
+ address out for fifteen minutes; twenty from anywhere close the page for
500
+ everyone for fifteen minutes.
501
+ - With OAuth on, `/mcp` never answers an unauthenticated request, whether or
502
+ not a read token is set.
503
+ - Grants live in `<data-dir>/oauth.json` as hashes. Delete the file to sign
504
+ every agent out at once, running server included; `wazap status` lists who
505
+ holds one. Disconnecting an agent on its side revokes its refresh token and
506
+ every access token it minted. A refresh token unused for ninety days is
507
+ dropped.
508
+ - A read grant never sees a write tool, whatever scope the agent requested.
509
+ The radio button on the consent page is the only thing that decides.
454
510
 
455
511
  ## Settings
456
512
 
@@ -464,6 +520,8 @@ Claude Code, Claude Desktop, Cursor, Codex, VS Code and any client with an "MCP
464
520
  | `WAZAP_TRANSPORT` | `stdio` | `stdio` or `http`. |
465
521
  | `WAZAP_HOST` / `WAZAP_PORT` | `127.0.0.1` / `8766` | HTTP bind address. |
466
522
  | `WAZAP_READ_TOKEN` / `WAZAP_WRITE_TOKEN` | unset | HTTP bearer tokens. |
523
+ | `WAZAP_PUBLIC_URL` | unset | The `https` address agents reach the server at. With the password, turns OAuth on. |
524
+ | `WAZAP_OAUTH_PASSWORD` | unset | What the consent page asks for. At least 8 characters. |
467
525
  | `WAZAP_NO_UPDATE_CHECK` | `0` | `1` stops `status` asking npm for a newer version. |
468
526
  | `WAZAP_TRANSCRIBE` | `off` | `local`, `openai` or `off`. |
469
527
  | `WAZAP_TRANSCRIBE_AUTO` | `1` | Transcribe incoming voice notes in the background. |
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { lockHolder, releaseLock, writeLock } from "./lock.js";
19
19
  import { log, logError, say } from "./logger.js";
20
20
  import { clockLabel, formatAge } from "./messages.js";
21
21
  import { RateLimiter } from "./ratelimit.js";
22
+ import { oauthProblem } from "./oauth.js";
22
23
  import { runHttp, runStdio, startLoopbackEndpoint } from "./server.js";
23
24
  import { applyWrites } from "./settings.js";
24
25
  import { MODELS, downloadModel, maskKey, modelSpec, readTranscribeSettings, stripPasted, transcribeFile, transcribeReady, } from "./transcribe/index.js";
@@ -350,6 +351,13 @@ export async function runServe(config) {
350
351
  say(fail(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`));
351
352
  process.exit(1);
352
353
  }
354
+ if (config.transport === "http") {
355
+ const problem = oauthProblem(config);
356
+ if (problem) {
357
+ say(fail(problem));
358
+ process.exit(1);
359
+ }
360
+ }
353
361
  mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
354
362
  if (writeLock(p.lockFile)) {
355
363
  claimed = true;
package/dist/config.js CHANGED
@@ -16,6 +16,7 @@ export function paths(dataDir) {
16
16
  storeFile: join(dataDir, "store.json"),
17
17
  lockFile: join(dataDir, "server.lock"),
18
18
  daemonFile: join(dataDir, "daemon.json"),
19
+ oauthFile: join(dataDir, "oauth.json"),
19
20
  envFile: join(dataDir, ".env"),
20
21
  qrFile: join(dataDir, "qr.png"),
21
22
  };
@@ -140,6 +141,8 @@ export function parseCli(argv = process.argv.slice(2)) {
140
141
  httpPort: values.port ? asInt(values.port, 8766) : asInt(process.env.WAZAP_PORT, 8766),
141
142
  readToken: (process.env.WAZAP_READ_TOKEN ?? "").trim() || null,
142
143
  writeToken: (process.env.WAZAP_WRITE_TOKEN ?? "").trim() || null,
144
+ publicUrl: (process.env.WAZAP_PUBLIC_URL ?? "").trim().replace(/\/+$/, "") || null,
145
+ oauthPassword: process.env.WAZAP_OAUTH_PASSWORD || null,
143
146
  share: !asBool(process.env.WAZAP_NO_SHARE, false),
144
147
  rateLimitPerMinute: asInt(process.env.WAZAP_RATE_LIMIT, 20),
145
148
  sources: {
package/dist/doctor.js CHANGED
@@ -3,6 +3,7 @@ import { readLinkedAccount } from "./auth-state.js";
3
3
  import { WAZAP_VERSION, paths } from "./config.js";
4
4
  import { asWazapError } from "./errors.js";
5
5
  import { lockHolder, lockPid } from "./lock.js";
6
+ import { oauthProblem, readGrants } from "./oauth.js";
6
7
  import { MODELS, findWhisper, localProvider, maskKey, modelPath, readTranscribeSettings, which, } from "./transcribe/index.js";
7
8
  import { dim, fail, fix, green, info, ok, red } from "./ui.js";
8
9
  export const MARK = { ok: "✓", fail: "✗", info: "–" };
@@ -16,6 +17,7 @@ const CHECKS = [
16
17
  checkLock,
17
18
  checkCredentials,
18
19
  checkWrites,
20
+ checkOAuth,
19
21
  checkTranscribe,
20
22
  checkUpdate,
21
23
  ];
@@ -107,6 +109,23 @@ function checkWrites(config) {
107
109
  detail: `${config.readOnly ? "off" : "on"} (${config.sources.readOnly})`,
108
110
  };
109
111
  }
112
+ /** Only when OAuth is configured: whether it can start, and who is signed in. */
113
+ function checkOAuth(config) {
114
+ if (!config.publicUrl && !config.oauthPassword)
115
+ return [];
116
+ const problem = oauthProblem(config);
117
+ if (problem)
118
+ return [{ name: "oauth", state: "fail", detail: problem, fix: "edit <data-dir>/.env" }];
119
+ if (config.transport !== "http") {
120
+ return [{ name: "oauth", state: "info", detail: "configured, but only served with WAZAP_TRANSPORT=http" }];
121
+ }
122
+ const grants = readGrants(paths(config.dataDir).oauthFile);
123
+ if (grants.length === 0) {
124
+ return [{ name: "oauth", state: "info", detail: `on at ${config.publicUrl}, no agent signed in yet` }];
125
+ }
126
+ const who = grants.map((g) => `${g.client} (${g.scopes.join("+")})`).join(", ");
127
+ return [{ name: "oauth", state: "ok", detail: `on at ${config.publicUrl}; signed in: ${who}` }];
128
+ }
110
129
  const TRANSCRIBE_OFF_FIX = "run `wazap config transcribe local` to transcribe voice messages";
111
130
  const DOWNLOAD_FIX = "run `wazap transcribe download`";
112
131
  const KEY_FIX = "run `wazap config transcribe openai`";
package/dist/index.js CHANGED
@@ -50,7 +50,8 @@ Options:
50
50
  -v, --version Show the version
51
51
 
52
52
  Environment: WAZAP_DATA_DIR, WAZAP_READ_ONLY, WAZAP_SYNC_FULL_HISTORY, WAZAP_PERSIST_HISTORY,
53
- WAZAP_TRANSPORT, WAZAP_HOST, WAZAP_PORT, WAZAP_READ_TOKEN, WAZAP_WRITE_TOKEN, WAZAP_RATE_LIMIT,
53
+ WAZAP_TRANSPORT, WAZAP_HOST, WAZAP_PORT, WAZAP_READ_TOKEN, WAZAP_WRITE_TOKEN, WAZAP_PUBLIC_URL,
54
+ WAZAP_OAUTH_PASSWORD, WAZAP_RATE_LIMIT,
54
55
  WAZAP_NO_SHARE, WAZAP_NO_UPDATE_CHECK, WAZAP_TRANSCRIBE, WAZAP_TRANSCRIBE_AUTO,
55
56
  WAZAP_TRANSCRIBE_LANGUAGE, WAZAP_TRANSCRIBE_API_KEY, WAZAP_TRANSCRIBE_URL, WAZAP_TRANSCRIBE_MODEL,
56
57
  WAZAP_WHISPER_MODEL, WAZAP_WHISPER_BIN.
package/dist/oauth.js ADDED
@@ -0,0 +1,498 @@
1
+ /**
2
+ * OAuth 2.1 for hosted agents. claude.ai, ChatGPT and Poke's OAuth mode will
3
+ * not take a static bearer token; they discover an authorization server, register
4
+ * themselves (RFC 7591), send the person to a login page and trade a code for
5
+ * tokens. The SDK's router does discovery, registration, PKCE and the token
6
+ * endpoint. This file is the part it cannot know: a login page guarded by one
7
+ * password, which scope the person granted, and where the tokens live.
8
+ *
9
+ * There is one user. The password in WAZAP_OAUTH_PASSWORD is the whole identity
10
+ * layer, so every grant is a deliberate act on the consent page, and the page
11
+ * asks read or write the way `wazap login` does.
12
+ */
13
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { dirname } from "node:path";
16
+ import { InvalidGrantError, InvalidScopeError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
17
+ import { WAZAP_VERSION } from "./config.js";
18
+ import { log } from "./logger.js";
19
+ export const OAUTH_SCOPES = ["read", "write"];
20
+ /** The consent form posts here; mounted by server.ts next to the SDK router. */
21
+ export const APPROVE_PATH = "/oauth/approve";
22
+ const ACCESS_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
23
+ const CODE_TTL_MS = 10 * 60 * 1000;
24
+ const PENDING_TTL_MS = 10 * 60 * 1000;
25
+ /** A refresh token nobody has used in this long is a forgotten one. */
26
+ const REFRESH_IDLE_MS = 90 * 24 * 60 * 60 * 1000;
27
+ /** A client that registered and never finished consent. */
28
+ const CLIENT_ORPHAN_MS = 60 * 60 * 1000;
29
+ const LOCKOUT_AFTER = 5;
30
+ const LOCKOUT_MS = 15 * 60 * 1000;
31
+ /** Wrong passwords from everywhere, together, before the page closes for a while. */
32
+ const GLOBAL_LOCKOUT_AFTER = 20;
33
+ /** Wrong passwords one consent page takes before it is thrown away. */
34
+ const PENDING_MISSES = 3;
35
+ const LOOPBACK_HOSTS = ["127.0.0.1", "[::1]", "localhost"];
36
+ /**
37
+ * OAuth needs both halves and an issuer the SDK and a browser will accept:
38
+ * https (or loopback, for tests), no path, since every endpoint is mounted at
39
+ * the root of whatever host this is.
40
+ */
41
+ export function oauthProblem(config) {
42
+ if (!config.publicUrl && !config.oauthPassword)
43
+ return null;
44
+ if (!config.publicUrl)
45
+ return "WAZAP_OAUTH_PASSWORD is set but WAZAP_PUBLIC_URL is not. Set both, or neither.";
46
+ if (!config.oauthPassword)
47
+ return "WAZAP_PUBLIC_URL is set but WAZAP_OAUTH_PASSWORD is not. Set both, or neither.";
48
+ let url;
49
+ try {
50
+ url = new URL(config.publicUrl);
51
+ }
52
+ catch {
53
+ return `WAZAP_PUBLIC_URL is not a URL: ${config.publicUrl}`;
54
+ }
55
+ if (url.search || url.hash)
56
+ return "WAZAP_PUBLIC_URL must not carry a query or a fragment.";
57
+ if (url.pathname !== "/") {
58
+ return "WAZAP_PUBLIC_URL must be a bare origin: the OAuth endpoints live at its root, not under a path.";
59
+ }
60
+ if (url.protocol !== "https:" && !LOOPBACK_HOSTS.includes(url.hostname)) {
61
+ return "WAZAP_PUBLIC_URL must be https, since agents will send a password to it.";
62
+ }
63
+ if (config.oauthPassword.length < 8)
64
+ return "WAZAP_OAUTH_PASSWORD is shorter than 8 characters.";
65
+ return null;
66
+ }
67
+ function sha256(value) {
68
+ return createHash("sha256").update(value).digest("hex");
69
+ }
70
+ function sameSecret(a, b) {
71
+ const x = Buffer.from(sha256(a));
72
+ const y = Buffer.from(sha256(b));
73
+ return timingSafeEqual(x, y);
74
+ }
75
+ function escapeHtml(value) {
76
+ return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
77
+ }
78
+ /** Keep the scopes we know; a client asking for nothing gets read. */
79
+ function normalizeScopes(requested) {
80
+ const known = (requested ?? []).filter((s) => OAUTH_SCOPES.includes(s));
81
+ return known.length > 0 ? Array.from(new Set(known)) : ["read"];
82
+ }
83
+ function emptyState() {
84
+ return { clients: {}, access: {}, refresh: {} };
85
+ }
86
+ function loadState(file) {
87
+ if (!existsSync(file))
88
+ return emptyState();
89
+ try {
90
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
91
+ return {
92
+ clients: parsed.clients ?? {},
93
+ access: parsed.access ?? {},
94
+ refresh: parsed.refresh ?? {},
95
+ };
96
+ }
97
+ catch (err) {
98
+ log(`oauth.json unreadable (${err instanceof Error ? err.message : String(err)}), starting with no grants`);
99
+ return emptyState();
100
+ }
101
+ }
102
+ function saveState(file, state) {
103
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
104
+ // The mode argument only applies on creation; the chmod covers a leftover temp file.
105
+ const tmp = `${file}.${process.pid}.tmp`;
106
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
107
+ chmodSync(tmp, 0o600);
108
+ renameSync(tmp, file);
109
+ }
110
+ /**
111
+ * Failed passwords, per caller and in total. Five misses lock that caller out
112
+ * for fifteen minutes; twenty misses from anywhere lock the page for everyone,
113
+ * so rotating addresses buys an attacker nothing. A consent page itself takes
114
+ * three wrong passwords and is then gone, which makes every further guess cost
115
+ * a fresh /authorize, an endpoint the SDK rate-limits.
116
+ */
117
+ class Lockout {
118
+ now;
119
+ misses = new Map();
120
+ global = { count: 0, at: 0, until: 0 };
121
+ constructor(now) {
122
+ this.now = now;
123
+ }
124
+ locked(key) {
125
+ const now = this.now();
126
+ if (this.global.until > now)
127
+ return true;
128
+ const entry = this.misses.get(key);
129
+ return entry !== undefined && entry.until > now;
130
+ }
131
+ miss(key) {
132
+ const now = this.now();
133
+ const entry = this.misses.get(key) ?? { count: 0, at: now, until: 0 };
134
+ entry.count += 1;
135
+ entry.at = now;
136
+ if (entry.count >= LOCKOUT_AFTER) {
137
+ entry.until = now + LOCKOUT_MS;
138
+ entry.count = 0;
139
+ }
140
+ this.misses.set(key, entry);
141
+ if (now - this.global.at > LOCKOUT_MS)
142
+ this.global = { count: 0, at: now, until: 0 };
143
+ this.global.count += 1;
144
+ if (this.global.count >= GLOBAL_LOCKOUT_AFTER) {
145
+ this.global = { count: 0, at: now, until: now + LOCKOUT_MS };
146
+ log("oauth: too many wrong passwords from everywhere, consent closed for fifteen minutes");
147
+ }
148
+ }
149
+ clear(key) {
150
+ this.misses.delete(key);
151
+ }
152
+ /** Forget callers whose misses are older than the window. */
153
+ prune() {
154
+ const now = this.now();
155
+ for (const [key, entry] of this.misses) {
156
+ if (entry.until <= now && now - entry.at > LOCKOUT_MS)
157
+ this.misses.delete(key);
158
+ }
159
+ }
160
+ }
161
+ function grantsOf(state) {
162
+ return Object.values(state.refresh).map((entry) => ({
163
+ client: state.clients[entry.clientId]?.client_name ?? entry.clientId,
164
+ scopes: entry.scopes,
165
+ issuedAt: entry.issuedAt,
166
+ }));
167
+ }
168
+ /** The grants on disk, read by a process that is not the server. */
169
+ export function readGrants(stateFile) {
170
+ return grantsOf(loadState(stateFile));
171
+ }
172
+ export class WazapOAuthProvider {
173
+ options;
174
+ state;
175
+ pending = new Map();
176
+ codes = new Map();
177
+ lockout;
178
+ now;
179
+ clientsStore;
180
+ constructor(options) {
181
+ this.options = options;
182
+ this.now = options.now ?? Date.now;
183
+ this.state = loadState(options.stateFile);
184
+ this.lockout = new Lockout(this.now);
185
+ this.clientsStore = {
186
+ getClient: (clientId) => this.state.clients[clientId],
187
+ registerClient: (client) => {
188
+ const full = {
189
+ ...client,
190
+ client_id: randomBytes(16).toString("hex"),
191
+ client_id_issued_at: Math.floor(this.now() / 1000),
192
+ };
193
+ this.state.clients[full.client_id] = full;
194
+ this.persist();
195
+ log(`oauth: registered client "${full.client_name ?? full.client_id}"`);
196
+ return full;
197
+ },
198
+ };
199
+ }
200
+ /** The authorization server and the protected resource are one process. */
201
+ get issuerUrl() {
202
+ return this.options.publicUrl;
203
+ }
204
+ get resourceUrl() {
205
+ return new URL("/mcp", this.options.publicUrl);
206
+ }
207
+ persist() {
208
+ saveState(this.options.stateFile, this.state);
209
+ }
210
+ /** Deleting oauth.json is the documented way to sign everyone out; honour it while running. */
211
+ sync() {
212
+ const populated = Object.keys(this.state.clients).length + Object.keys(this.state.access).length + Object.keys(this.state.refresh).length > 0;
213
+ if (populated && !existsSync(this.options.stateFile)) {
214
+ log("oauth: oauth.json is gone, every grant is revoked");
215
+ this.state.clients = {};
216
+ this.state.access = {};
217
+ this.state.refresh = {};
218
+ this.codes.clear();
219
+ }
220
+ }
221
+ sweep() {
222
+ this.sync();
223
+ this.lockout.prune();
224
+ const now = this.now();
225
+ for (const [id, entry] of this.pending)
226
+ if (now - entry.createdAt > PENDING_TTL_MS)
227
+ this.pending.delete(id);
228
+ for (const [code, entry] of this.codes)
229
+ if (now - entry.createdAt > CODE_TTL_MS)
230
+ this.codes.delete(code);
231
+ let dirty = false;
232
+ for (const [hash, entry] of Object.entries(this.state.access)) {
233
+ if (entry.expiresAt !== undefined && entry.expiresAt < now) {
234
+ delete this.state.access[hash];
235
+ dirty = true;
236
+ }
237
+ }
238
+ for (const [hash, entry] of Object.entries(this.state.refresh)) {
239
+ if (now - (entry.lastUsedAt ?? entry.issuedAt) > REFRESH_IDLE_MS) {
240
+ delete this.state.refresh[hash];
241
+ dirty = true;
242
+ }
243
+ }
244
+ // A client with no grant and nothing in flight is a registration nobody finished.
245
+ const holding = new Set();
246
+ for (const entry of Object.values(this.state.refresh))
247
+ holding.add(entry.clientId);
248
+ for (const entry of Object.values(this.state.access))
249
+ holding.add(entry.clientId);
250
+ for (const entry of this.pending.values())
251
+ holding.add(entry.client.client_id);
252
+ for (const entry of this.codes.values())
253
+ holding.add(entry.clientId);
254
+ for (const [id, client] of Object.entries(this.state.clients)) {
255
+ const issuedAt = (client.client_id_issued_at ?? 0) * 1000;
256
+ if (!holding.has(id) && now - issuedAt > CLIENT_ORPHAN_MS) {
257
+ delete this.state.clients[id];
258
+ dirty = true;
259
+ }
260
+ }
261
+ if (dirty)
262
+ this.persist();
263
+ }
264
+ // --- authorization -------------------------------------------------------
265
+ /** The SDK has validated client and redirect_uri; park the request and show the page. */
266
+ async authorize(client, params, res) {
267
+ this.sweep();
268
+ const id = randomBytes(24).toString("hex");
269
+ this.pending.set(id, { client, params, createdAt: this.now(), misses: 0 });
270
+ res.setHeader("Cache-Control", "no-store");
271
+ res.status(200).type("html").send(this.consentPage(id, client, params));
272
+ }
273
+ /** Express handler for the consent form. Mount with urlencoded parsing. */
274
+ approve = (req, res) => {
275
+ this.sweep();
276
+ const body = (req.body ?? {});
277
+ const id = typeof body.request === "string" ? body.request : "";
278
+ const entry = this.pending.get(id);
279
+ if (!entry) {
280
+ res.status(400).type("html").send(this.messagePage("This sign-in link has expired. Go back to the agent and connect again."));
281
+ return;
282
+ }
283
+ const { client, params } = entry;
284
+ const fail = (error, description) => {
285
+ this.pending.delete(id);
286
+ const url = new URL(params.redirectUri);
287
+ url.searchParams.set("error", error);
288
+ url.searchParams.set("error_description", description);
289
+ if (params.state)
290
+ url.searchParams.set("state", params.state);
291
+ res.redirect(url.href);
292
+ };
293
+ if (body.decision !== "allow") {
294
+ fail("access_denied", "The user declined.");
295
+ return;
296
+ }
297
+ const caller = req.ip ?? "unknown";
298
+ if (this.lockout.locked(caller)) {
299
+ res.status(429).type("html").send(this.messagePage("Too many wrong passwords. Try again in fifteen minutes."));
300
+ return;
301
+ }
302
+ const password = typeof body.password === "string" ? body.password : "";
303
+ if (!sameSecret(password, this.options.password)) {
304
+ this.lockout.miss(caller);
305
+ entry.misses += 1;
306
+ log(`oauth: wrong password from ${caller}`);
307
+ if (entry.misses >= PENDING_MISSES) {
308
+ this.pending.delete(id);
309
+ res.status(401).type("html").send(this.messagePage("Wrong password, three times. Go back to the agent and connect again."));
310
+ return;
311
+ }
312
+ res.status(401).type("html").send(this.consentPage(id, client, params, "Wrong password."));
313
+ return;
314
+ }
315
+ this.lockout.clear(caller);
316
+ this.pending.delete(id);
317
+ const scopes = body.access === "write" ? ["read", "write"] : ["read"];
318
+ const code = randomBytes(32).toString("hex");
319
+ this.codes.set(code, {
320
+ clientId: client.client_id,
321
+ codeChallenge: params.codeChallenge,
322
+ redirectUri: params.redirectUri,
323
+ scopes,
324
+ createdAt: this.now(),
325
+ });
326
+ log(`oauth: granted ${scopes.join("+")} to "${client.client_name ?? client.client_id}"`);
327
+ const url = new URL(params.redirectUri);
328
+ url.searchParams.set("code", code);
329
+ if (params.state)
330
+ url.searchParams.set("state", params.state);
331
+ res.redirect(url.href);
332
+ };
333
+ async challengeForAuthorizationCode(client, code) {
334
+ const entry = this.codes.get(code);
335
+ if (!entry || entry.clientId !== client.client_id)
336
+ throw new InvalidGrantError("Unknown authorization code");
337
+ return entry.codeChallenge;
338
+ }
339
+ async exchangeAuthorizationCode(client, code, _codeVerifier, redirectUri) {
340
+ this.sweep();
341
+ const entry = this.codes.get(code);
342
+ if (!entry || entry.clientId !== client.client_id)
343
+ throw new InvalidGrantError("Unknown authorization code");
344
+ if (redirectUri !== undefined && redirectUri !== entry.redirectUri)
345
+ throw new InvalidGrantError("redirect_uri mismatch");
346
+ // One use: a replayed code must fail even inside its ten minutes.
347
+ this.codes.delete(code);
348
+ return this.issue(client.client_id, entry.scopes);
349
+ }
350
+ async exchangeRefreshToken(client, refreshToken, scopes) {
351
+ this.sweep();
352
+ const entry = this.state.refresh[sha256(refreshToken)];
353
+ if (!entry || entry.clientId !== client.client_id)
354
+ throw new InvalidGrantError("Unknown refresh token");
355
+ // A refresh may narrow the grant, never widen it.
356
+ const granted = scopes && scopes.length > 0 ? scopes.filter((s) => entry.scopes.includes(s)) : entry.scopes;
357
+ if (granted.length === 0)
358
+ throw new InvalidScopeError("Requested scopes exceed the grant");
359
+ return this.issue(client.client_id, granted, refreshToken);
360
+ }
361
+ issue(clientId, scopes, existingRefresh) {
362
+ const now = this.now();
363
+ let refreshToken = existingRefresh;
364
+ if (!refreshToken) {
365
+ refreshToken = randomBytes(32).toString("hex");
366
+ this.state.refresh[sha256(refreshToken)] = { clientId, scopes, issuedAt: now, lastUsedAt: now };
367
+ }
368
+ else {
369
+ const refresh = this.state.refresh[sha256(refreshToken)];
370
+ if (refresh)
371
+ refresh.lastUsedAt = now;
372
+ }
373
+ const accessToken = randomBytes(32).toString("hex");
374
+ this.state.access[sha256(accessToken)] = {
375
+ clientId,
376
+ scopes,
377
+ issuedAt: now,
378
+ expiresAt: now + ACCESS_TOKEN_TTL_MS,
379
+ refresh: sha256(refreshToken),
380
+ };
381
+ this.persist();
382
+ return {
383
+ access_token: accessToken,
384
+ token_type: "bearer",
385
+ expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
386
+ refresh_token: refreshToken,
387
+ scope: scopes.join(" "),
388
+ };
389
+ }
390
+ async verifyAccessToken(token) {
391
+ this.sync();
392
+ const entry = this.state.access[sha256(token)];
393
+ if (!entry)
394
+ throw new InvalidTokenError("Unknown access token");
395
+ if (entry.expiresAt !== undefined && entry.expiresAt < this.now())
396
+ throw new InvalidTokenError("Access token expired");
397
+ return {
398
+ token,
399
+ clientId: entry.clientId,
400
+ scopes: entry.scopes,
401
+ expiresAt: entry.expiresAt === undefined ? undefined : Math.floor(entry.expiresAt / 1000),
402
+ resource: this.resourceUrl,
403
+ };
404
+ }
405
+ /**
406
+ * Look the token up as either kind; the caller may not say which. Revoking a
407
+ * refresh token also ends every access token it minted, so "disconnect" in
408
+ * an agent's settings means disconnected now, not in up to a day.
409
+ */
410
+ async revokeToken(client, request) {
411
+ this.sync();
412
+ const hash = sha256(request.token);
413
+ let dirty = false;
414
+ const access = this.state.access[hash];
415
+ if (access && access.clientId === client.client_id) {
416
+ delete this.state.access[hash];
417
+ dirty = true;
418
+ }
419
+ const refresh = this.state.refresh[hash];
420
+ if (refresh && refresh.clientId === client.client_id) {
421
+ delete this.state.refresh[hash];
422
+ for (const [accessHash, entry] of Object.entries(this.state.access)) {
423
+ if (entry.refresh === hash)
424
+ delete this.state.access[accessHash];
425
+ }
426
+ dirty = true;
427
+ }
428
+ if (dirty)
429
+ this.persist();
430
+ }
431
+ /** Every grant, for `wazap status` and the like. */
432
+ grants() {
433
+ this.sync();
434
+ return grantsOf(this.state);
435
+ }
436
+ // --- pages ---------------------------------------------------------------
437
+ consentPage(id, client, params, error) {
438
+ const rawName = client.client_name ?? new URL(params.redirectUri).hostname;
439
+ const name = escapeHtml(rawName);
440
+ const wantsWrite = normalizeScopes(params.scopes).includes("write");
441
+ return page(`Connect ${rawName}`, `
442
+ <h1>Connect <strong>${name}</strong> to WhatsApp?</h1>
443
+ <p>This agent wants to use the WhatsApp account behind this wazap.</p>
444
+ ${error ? `<p class="error">${escapeHtml(error)}</p>` : ""}
445
+ <form method="post" action="${APPROVE_PATH}">
446
+ <input type="hidden" name="request" value="${id}">
447
+ <fieldset>
448
+ <legend>What may it do?</legend>
449
+ <label><input type="radio" name="access" value="read"${wantsWrite ? "" : " checked"}> Read chats and contacts</label>
450
+ <label><input type="radio" name="access" value="write"${wantsWrite ? " checked" : ""}> Read, and send messages as you</label>
451
+ </fieldset>
452
+ <label class="field">wazap password
453
+ <input type="password" name="password" autocomplete="current-password" autofocus required>
454
+ </label>
455
+ <div class="actions">
456
+ <button type="submit" name="decision" value="allow">Connect</button>
457
+ <button type="submit" name="decision" value="deny" class="secondary" formnovalidate>Cancel</button>
458
+ </div>
459
+ </form>`);
460
+ }
461
+ messagePage(text) {
462
+ return page("wazap", `<h1>wazap</h1><p>${escapeHtml(text)}</p>`);
463
+ }
464
+ }
465
+ function page(title, body) {
466
+ return `<!doctype html>
467
+ <html lang="en">
468
+ <head>
469
+ <meta charset="utf-8">
470
+ <meta name="viewport" content="width=device-width, initial-scale=1">
471
+ <meta name="robots" content="noindex">
472
+ <title>${escapeHtml(title)} · wazap</title>
473
+ <style>
474
+ :root { color-scheme: light dark; }
475
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; background: Canvas; color: CanvasText; }
476
+ main { width: min(28rem, calc(100vw - 2rem)); padding: 2rem; border: 1px solid color-mix(in srgb, CanvasText 15%, transparent); border-radius: 12px; }
477
+ h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
478
+ p { margin: 0 0 1rem; }
479
+ fieldset { border: 0; padding: 0; margin: 0 0 1rem; }
480
+ legend { font-weight: 600; margin-bottom: .25rem; }
481
+ label { display: block; margin: .25rem 0; }
482
+ .field { font-weight: 600; margin-bottom: 1rem; }
483
+ .field input { display: block; width: 100%; box-sizing: border-box; margin-top: .25rem; padding: .5rem .6rem; font: inherit; border: 1px solid color-mix(in srgb, CanvasText 30%, transparent); border-radius: 8px; background: Field; color: FieldText; }
484
+ .actions { display: flex; gap: .5rem; }
485
+ button { font: inherit; padding: .55rem 1rem; border-radius: 8px; border: 1px solid #25d366; background: #25d366; color: #062b14; cursor: pointer; }
486
+ button.secondary { background: transparent; border-color: color-mix(in srgb, CanvasText 30%, transparent); color: inherit; }
487
+ .error { color: #c62828; font-weight: 600; }
488
+ footer { margin-top: 1.5rem; font-size: .8rem; opacity: .6; }
489
+ </style>
490
+ </head>
491
+ <body>
492
+ <main>
493
+ ${body}
494
+ <footer>wazap ${escapeHtml(WAZAP_VERSION)}</footer>
495
+ </main>
496
+ </body>
497
+ </html>`;
498
+ }
package/dist/server.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
6
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
6
7
  import express from "express";
7
- import { WAZAP_VERSION } from "./config.js";
8
+ import { rateLimit } from "express-rate-limit";
9
+ import { WAZAP_VERSION, paths } from "./config.js";
10
+ import { APPROVE_PATH, OAUTH_SCOPES, WazapOAuthProvider } from "./oauth.js";
8
11
  import { registerTools } from "./tools.js";
9
12
  import { log, logError } from "./logger.js";
10
13
  function isAuthorized(header, expected) {
@@ -35,29 +38,72 @@ export async function startHttpEndpoint(wa, config, endpoint, limiter) {
35
38
  const rpc = (req.body && typeof req.body === "object" ? req.body.method : undefined) ?? "-";
36
39
  const hasAuth = req.headers.authorization ? "auth" : "noauth";
37
40
  res.on("finish", () => {
38
- log(`HTTP ${req.method} ${req.url} rpc=${rpc} ${hasAuth} accept="${req.headers.accept ?? ""}" -> ${res.statusCode} (${Date.now() - start}ms)`);
41
+ log(`HTTP ${req.method} ${req.originalUrl} rpc=${rpc} ${hasAuth} accept="${req.headers.accept ?? ""}" -> ${res.statusCode} (${Date.now() - start}ms)`);
39
42
  });
40
43
  res.on("close", () => {
41
44
  if (!res.writableEnded) {
42
- log(`HTTP ${req.method} ${req.url} rpc=${rpc} -> client closed before response (${Date.now() - start}ms)`);
45
+ log(`HTTP ${req.method} ${req.originalUrl} rpc=${rpc} -> client closed before response (${Date.now() - start}ms)`);
43
46
  }
44
47
  });
45
48
  next();
46
49
  });
47
- if (endpoint.openRead) {
50
+ if (endpoint.openRead && !endpoint.oauth) {
48
51
  log("WARNING: no WAZAP_READ_TOKEN set, the /mcp endpoint is UNAUTHENTICATED. " +
49
52
  "Set WAZAP_READ_TOKEN before exposing this server beyond localhost.");
50
53
  }
54
+ const oauth = endpoint.oauth;
55
+ // A server that advertises sign-in must not also answer strangers.
56
+ const openRead = endpoint.openRead && !oauth;
57
+ if (oauth) {
58
+ // Reached through a TLS proxy: on this machine, or the Docker bridge when
59
+ // the container binds 0.0.0.0. The proxy's idea of the caller is the one
60
+ // the password lockout and the SDK's limiters should count.
61
+ app.set("trust proxy", "loopback, linklocal, uniquelocal");
62
+ app.use(mcpAuthRouter({
63
+ provider: oauth,
64
+ issuerUrl: oauth.issuerUrl,
65
+ resourceServerUrl: oauth.resourceUrl,
66
+ resourceName: "wazap",
67
+ scopesSupported: [...OAUTH_SCOPES],
68
+ serviceDocumentationUrl: new URL("https://github.com/razvangirgiz/wazap#self-host"),
69
+ // A confidential client's secret would otherwise expire after thirty
70
+ // days and its refresh token with it, which is a monthly password.
71
+ clientRegistrationOptions: { clientSecretExpirySeconds: 0 },
72
+ }));
73
+ app.post(APPROVE_PATH, rateLimit({ windowMs: 15 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false }), express.urlencoded({ extended: false }), oauth.approve);
74
+ log(`OAuth on: agents sign in at ${oauth.issuerUrl.href}`);
75
+ }
76
+ const resourceMetadataUrl = oauth ? getOAuthProtectedResourceMetadataUrl(oauth.resourceUrl) : null;
51
77
  // The first credential the bearer token matches decides the session's tools,
52
- // so a leaked read token can never message anyone.
53
- const requireAuth = (req, res, next) => {
78
+ // so a leaked read token can never message anyone. An OAuth token carries
79
+ // the scope the person picked on the consent page.
80
+ const requireAuth = async (req, res, next) => {
54
81
  const auth = req.headers.authorization;
55
82
  const credential = endpoint.credentials.find((entry) => isAuthorized(auth, entry.token));
56
- if (credential || endpoint.openRead) {
57
- req.mcpWrite = credential?.write === true;
83
+ if (credential) {
84
+ req.mcpWrite = credential.write;
58
85
  next();
59
86
  return;
60
87
  }
88
+ if (oauth && auth?.startsWith("Bearer ")) {
89
+ try {
90
+ const info = await oauth.verifyAccessToken(auth.slice("Bearer ".length).trim());
91
+ req.mcpWrite = info.scopes.includes("write");
92
+ next();
93
+ return;
94
+ }
95
+ catch {
96
+ // Falls through to the 401 below, which tells the client how to sign in.
97
+ }
98
+ }
99
+ if (openRead) {
100
+ req.mcpWrite = false;
101
+ next();
102
+ return;
103
+ }
104
+ if (resourceMetadataUrl) {
105
+ res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${resourceMetadataUrl}"`);
106
+ }
61
107
  res.status(401).json({
62
108
  jsonrpc: "2.0",
63
109
  error: { code: -32001, message: "Unauthorized: missing or invalid bearer token" },
@@ -110,9 +156,12 @@ export async function startHttpEndpoint(wa, config, endpoint, limiter) {
110
156
  }
111
157
  }
112
158
  };
113
- app.post("/mcp", requireAuth, handleMcp);
114
- app.get("/mcp", requireAuth, handleMcp);
115
- app.delete("/mcp", requireAuth, handleMcp);
159
+ const authed = (req, res, next) => {
160
+ requireAuth(req, res, next).catch(next);
161
+ };
162
+ app.post("/mcp", authed, handleMcp);
163
+ app.get("/mcp", authed, handleMcp);
164
+ app.delete("/mcp", authed, handleMcp);
116
165
  // Unauthenticated, so it carries liveness only; the account and data dir
117
166
  // stay behind the token in get_status.
118
167
  app.get("/healthz", (_req, res) => {
@@ -123,6 +172,12 @@ export async function startHttpEndpoint(wa, config, endpoint, limiter) {
123
172
  const bound = server.address();
124
173
  resolve(typeof bound === "object" && bound !== null ? bound.port : endpoint.port);
125
174
  });
175
+ endpoint.signal?.addEventListener("abort", () => {
176
+ for (const transport of transports.values())
177
+ void transport.close();
178
+ server.closeAllConnections();
179
+ server.close();
180
+ });
126
181
  });
127
182
  }
128
183
  /** The endpoint the user asked for: WAZAP_HOST/WAZAP_PORT and the two configured tokens. */
@@ -134,7 +189,14 @@ export async function runHttp(wa, config, limiter, extra) {
134
189
  credentials.push({ token: config.writeToken, write: true });
135
190
  if (extra)
136
191
  credentials.push(extra);
137
- const port = await startHttpEndpoint(wa, config, { host: config.httpHost, port: config.httpPort, credentials, openRead: !config.readToken }, limiter);
192
+ const oauth = config.publicUrl && config.oauthPassword
193
+ ? new WazapOAuthProvider({
194
+ publicUrl: new URL(config.publicUrl),
195
+ password: config.oauthPassword,
196
+ stateFile: paths(config.dataDir).oauthFile,
197
+ })
198
+ : undefined;
199
+ const port = await startHttpEndpoint(wa, config, { host: config.httpHost, port: config.httpPort, credentials, openRead: !config.readToken, oauth }, limiter);
138
200
  log(`MCP server (Streamable HTTP) on http://${config.httpHost}:${port}/mcp`);
139
201
  return port;
140
202
  }
package/dist/settings.js CHANGED
@@ -41,7 +41,9 @@ const SETTINGS = [
41
41
  {
42
42
  label: "transport",
43
43
  source: "transport",
44
- value: (config) => (config.transport === "http" ? `http ${config.httpHost}:${config.httpPort}` : "stdio"),
44
+ value: (config) => config.transport === "http"
45
+ ? `http ${config.httpHost}:${config.httpPort}${config.publicUrl && config.oauthPassword ? ` · oauth at ${config.publicUrl}` : ""}`
46
+ : "stdio",
45
47
  },
46
48
  {
47
49
  label: "rate limit",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "wazap-mcp",
3
- "version": "0.9.8",
3
+ "version": "0.10.0",
4
4
  "mcpName": "io.github.razvangirgiz/wazap",
5
- "description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, 24 tools, stdio or token-protected HTTP.",
5
+ "description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, 24 tools, stdio or HTTP with bearer tokens and OAuth.",
6
6
  "license": "MIT",
7
7
  "author": "Răzvan Girgiz",
8
8
  "repository": {
@@ -52,6 +52,7 @@
52
52
  "baileys": "7.0.0-rc14",
53
53
  "dotenv": "^16.4.5",
54
54
  "express": "^4.22.2",
55
+ "express-rate-limit": "^8.2.1",
55
56
  "qrcode": "^1.5.4",
56
57
  "qrcode-terminal": "^0.12.0",
57
58
  "zod": "^3.24.1"