wazap-mcp 0.9.2 → 0.9.3

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
@@ -186,14 +186,32 @@ created `0700` with credentials written `0600`:
186
186
  history/ per-chat message history, so a restart is not amnesia
187
187
  store.json chat-list snapshot
188
188
  server.lock pid of the running server
189
+ daemon.json loopback endpoint a second wazap bridges to
189
190
  .env optional settings, see .env.example
190
191
  ```
191
192
 
192
193
  Credential writes go to a temp file and are renamed into place, so killing the
193
194
  process mid-write cannot leave you re-linking your phone.
194
195
 
195
- One server per data directory: a second `wazap serve` on the same directory
196
- exits with code 2 and tells you the pid of the one already running.
196
+ ## Several clients at once
197
+
198
+ Claude Desktop, Claude Code and Cursor each launch their own `wazap`. WhatsApp
199
+ allows one socket per linked device, so they share one session instead of
200
+ fighting over it. The first `wazap` on a data directory owns the session and
201
+ opens an MCP endpoint on `127.0.0.1`; every later one bridges to it over that
202
+ endpoint. There is nothing to configure, and no client can tell the difference.
203
+ The owner publishes `<data-dir>/daemon.json` (`0600`) with its pid, its port
204
+ and the token a bridge authenticates with.
205
+
206
+ A bridge serves whatever the owner exposes, so an owner started `--read-only`
207
+ makes every client read-only, whatever flags that client was launched with.
208
+
209
+ When the owner exits, the bridges exit with it, and the next `wazap` a client
210
+ starts becomes the new owner.
211
+
212
+ `WAZAP_NO_SHARE=1` opts out: a second `wazap` on the same directory exits with
213
+ code 2 naming the pid of the one already running. An explicit `--http` is a
214
+ server of its own rather than a bridge, and is refused the same way.
197
215
 
198
216
  ## Read-only mode
199
217
 
package/dist/bridge.js ADDED
@@ -0,0 +1,64 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { CallToolRequestSchema, CallToolResultSchema, ListToolsRequestSchema, ListToolsResultSchema, } from "@modelcontextprotocol/sdk/types.js";
6
+ import { WAZAP_VERSION } from "./config.js";
7
+ import { readDaemon } from "./daemon.js";
8
+ import { log } from "./logger.js";
9
+ const HEARTBEAT_MS = 1_000;
10
+ /**
11
+ * Serve this client from the session another process already owns: an MCP server
12
+ * on our stdio, every tool call forwarded to the daemon's loopback endpoint and
13
+ * its answer returned untouched.
14
+ *
15
+ * `daemonFile` is here because the heartbeat re-reads the sidecar, and DaemonInfo
16
+ * carries no path.
17
+ */
18
+ export async function runBridge(daemon, daemonFile) {
19
+ let left = false;
20
+ /** Exit 1 so the client restarts us, and the restart becomes the new daemon. */
21
+ const leave = (reason) => {
22
+ if (left)
23
+ return;
24
+ left = true;
25
+ log(`${reason}, exiting so the next start can own the session`);
26
+ process.exit(1);
27
+ };
28
+ const client = new Client({ name: "wazap-bridge", version: WAZAP_VERSION });
29
+ await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${daemon.port}/mcp`), {
30
+ requestInit: { headers: { Authorization: `Bearer ${daemon.token}` } },
31
+ }));
32
+ const caps = client.getServerCapabilities() ?? {};
33
+ const server = new Server(client.getServerVersion() ?? { name: "wazap", version: daemon.version }, {
34
+ // Only what we forward: the daemon has no resources or prompts, and we have
35
+ // no handler for them.
36
+ capabilities: { tools: caps.tools ?? {} },
37
+ instructions: client.getInstructions(),
38
+ });
39
+ server.setRequestHandler(ListToolsRequestSchema, (req) => client.request({ method: "tools/list", params: req.params }, ListToolsResultSchema));
40
+ server.setRequestHandler(CallToolRequestSchema, (req) => client.request({ method: "tools/call", params: req.params }, CallToolResultSchema));
41
+ client.onclose = () => leave(`the session holder (pid ${daemon.pid}) closed the connection`);
42
+ client.onerror = () => leave(`lost the connection to the session holder (pid ${daemon.pid})`);
43
+ // A dead daemon does not close the client: the transport retries its stream and
44
+ // reports nothing, measured. So the liveness of the pid is ours to watch.
45
+ const heartbeat = setInterval(() => {
46
+ if (readDaemon(daemonFile)?.pid !== daemon.pid) {
47
+ leave(`the session holder (pid ${daemon.pid}) gave up the session`);
48
+ return;
49
+ }
50
+ try {
51
+ process.kill(daemon.pid, 0);
52
+ }
53
+ catch {
54
+ leave(`the session holder (pid ${daemon.pid}) is gone`);
55
+ }
56
+ }, HEARTBEAT_MS);
57
+ heartbeat.unref();
58
+ await server.connect(new StdioServerTransport());
59
+ log(`sharing the WhatsApp session held by pid ${daemon.pid}`);
60
+ // Our own client leaving is not a failure. The upstream stream holds the event
61
+ // loop open, so without this the bridge outlives the client it was started for.
62
+ process.stdin.on("end", () => process.exit(0));
63
+ process.stdin.on("close", () => process.exit(0));
64
+ }
package/dist/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { mkdirSync, rmSync } from "node:fs";
2
3
  import { createInterface } from "node:readline/promises";
3
4
  import { setTimeout as sleep } from "node:timers/promises";
@@ -6,15 +7,18 @@ import qrcode from "qrcode";
6
7
  import qrcodeTerminal from "qrcode-terminal";
7
8
  import { clearAuth, readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
8
9
  import { banner } from "./banner.js";
10
+ import { runBridge } from "./bridge.js";
9
11
  import { BAILEYS_VERSION, WAZAP_VERSION, paths } from "./config.js";
10
12
  import { connectNext } from "./connect.js";
13
+ import { decideRole, readDaemon, removeDaemon, writeDaemon } from "./daemon.js";
11
14
  import { checkLine, checkLines, runChecks } from "./doctor.js";
12
15
  import { RELINK_FIX, WazapError, asWazapError } from "./errors.js";
13
16
  import { normalizePhone } from "./ids.js";
14
17
  import { lockHolder, releaseLock, writeLock } from "./lock.js";
15
18
  import { log, logError, say } from "./logger.js";
16
19
  import { formatAge } from "./messages.js";
17
- import { runHttp, runStdio } from "./server.js";
20
+ import { RateLimiter } from "./ratelimit.js";
21
+ import { runHttp, runStdio, startLoopbackEndpoint } from "./server.js";
18
22
  import { applyWrites } from "./settings.js";
19
23
  import { bold, box, brand, humanLayout, dim, fail, info, maskNumber, next, ok, shortPath, spinner, step, tilde, warn, } from "./ui.js";
20
24
  import { WA_BROWSER, WhatsAppService } from "./whatsapp.js";
@@ -30,6 +34,8 @@ const SETTLED_STATUSES = [
30
34
  ];
31
35
  const LOGOUT_TIMEOUT_MS = 10_000;
32
36
  const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"];
37
+ /** Bind addresses a loopback bridge can still reach; the wildcards include 127.0.0.1. */
38
+ const SHAREABLE_HOSTS = [...LOOPBACK_HOSTS, "0.0.0.0", "::"];
33
39
  const SILENT_LOGGER = {
34
40
  level: "silent",
35
41
  child: () => SILENT_LOGGER,
@@ -49,6 +55,11 @@ export async function runStatus(config) {
49
55
  catch {
50
56
  unreadable = true;
51
57
  }
58
+ // A sidecar outliving the process that wrote it is stale, so only the lock
59
+ // holder's own record counts as a session being shared.
60
+ const serverPid = lockHolder(p.lockFile);
61
+ const daemon = readDaemon(p.daemonFile);
62
+ const sharing = daemon !== null && daemon.pid === serverPid ? { pid: daemon.pid, port: daemon.port } : null;
52
63
  const report = {
53
64
  data_dir: config.dataDir,
54
65
  linked: account !== null,
@@ -56,7 +67,8 @@ export async function runStatus(config) {
56
67
  account,
57
68
  wazap_version: WAZAP_VERSION,
58
69
  baileys_version: BAILEYS_VERSION,
59
- server_pid: lockHolder(p.lockFile),
70
+ server_pid: serverPid,
71
+ daemon: sharing,
60
72
  checks: await runChecks(config),
61
73
  };
62
74
  if (config.live)
@@ -89,9 +101,16 @@ function plainStatus(report) {
89
101
  else {
90
102
  lines.push("linked: no");
91
103
  }
92
- lines.push(`wazap: ${report.wazap_version}`, `baileys: ${report.baileys_version}`, report.server_pid === null ? "server: not running" : `server: running (pid ${report.server_pid})`, "", "checks:", ...report.checks.map(checkLine));
104
+ lines.push(`wazap: ${report.wazap_version}`, `baileys: ${report.baileys_version}`, `server: ${serverState(report)}`, "", "checks:", ...report.checks.map(checkLine));
93
105
  return lines;
94
106
  }
107
+ /** The one place the sidecar becomes words, so the two renderers cannot drift. */
108
+ function serverState(report) {
109
+ if (report.server_pid === null)
110
+ return "not running";
111
+ const shared = report.daemon === null ? "" : `, sharing on 127.0.0.1:${report.daemon.port}`;
112
+ return `running (pid ${report.server_pid}${shared})`;
113
+ }
95
114
  const LABEL_WIDTH = 8;
96
115
  function row(label, value) {
97
116
  return `${dim(label.padEnd(LABEL_WIDTH))} ${value}`;
@@ -106,7 +125,7 @@ function richStatus(report) {
106
125
  `${bold(`wazap ${report.wazap_version}`)}${dim(` · baileys ${report.baileys_version}`)}`,
107
126
  row("data dir", tilde(report.data_dir)),
108
127
  row("account", account),
109
- row("server", report.server_pid === null ? "not running" : `running (pid ${report.server_pid})`),
128
+ row("server", serverState(report)),
110
129
  "",
111
130
  ...report.checks.flatMap(checkLines),
112
131
  ];
@@ -120,15 +139,29 @@ function liveLines(live) {
120
139
  `live: last message ${live.last_message_age ?? "unknown"}`,
121
140
  ];
122
141
  }
142
+ /**
143
+ * Hold the session for a one-shot command: null once the lock is ours, otherwise
144
+ * the pid that owns it. The claim only fails while the file exists, so a lost
145
+ * race is looked up again rather than reported as a missing pid.
146
+ */
147
+ function takeSessionLock(lockFile) {
148
+ for (let attempt = 0; attempt < 5; attempt++) {
149
+ const running = lockHolder(lockFile);
150
+ if (running !== null)
151
+ return running;
152
+ if (writeLock(lockFile))
153
+ return null;
154
+ }
155
+ throw new WazapError("WHATSAPP_ERROR", `Could not take the session lock in ${lockFile}.`, "Run the command again");
156
+ }
123
157
  /** One process owns the session, so a probe only runs when no server holds the lock. */
124
158
  async function runLiveProbe(config) {
125
159
  const p = paths(config.dataDir);
126
- const running = lockHolder(p.lockFile);
160
+ // The probe owns the session for as long as it runs, exactly like the server.
161
+ const running = takeSessionLock(p.lockFile);
127
162
  if (running !== null) {
128
- throw new WazapError("WHATSAPP_ERROR", `A server (pid ${running}) already owns this session.`, "Ask it through your MCP client instead: call get_status");
163
+ throw new WazapError("WHATSAPP_ERROR", `A server (pid ${running}) already owns this session.`, "use get_status through your client, or wazap status");
129
164
  }
130
- // The probe owns the session for as long as it runs, exactly like the server.
131
- writeLock(p.lockFile);
132
165
  const wa = new WhatsAppService(config);
133
166
  const deadline = Date.now() + LIVE_TIMEOUT_MS;
134
167
  try {
@@ -181,22 +214,45 @@ export async function runGreet(config) {
181
214
  }
182
215
  export async function runServe(config) {
183
216
  const p = paths(config.dataDir);
184
- const running = lockHolder(p.lockFile);
185
- if (running !== null) {
186
- say(fail(`wazap is already running (pid ${running}) using ${config.dataDir}. Stop it first or use --data-dir.`));
187
- process.exit(2);
217
+ // Losing the atomic claim means another `serve` won it, and the next pass finds
218
+ // its sidecar and becomes a bridge onto it.
219
+ let claimed = false;
220
+ for (let attempt = 0; attempt < 5; attempt++) {
221
+ const role = await decideRole(config, p);
222
+ if (role.kind === "refuse") {
223
+ say(fail(role.message));
224
+ process.exit(2);
225
+ }
226
+ if (role.kind === "bridge") {
227
+ await runBridge(role.daemon, p.daemonFile);
228
+ return;
229
+ }
230
+ // Loopback with no token only gets runHttp's warning; off-loopback is refused.
231
+ if (config.transport === "http" && !config.readToken && !LOOPBACK_HOSTS.includes(config.httpHost)) {
232
+ say(fail(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`));
233
+ process.exit(1);
234
+ }
235
+ mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
236
+ if (writeLock(p.lockFile)) {
237
+ claimed = true;
238
+ break;
239
+ }
188
240
  }
189
- // Loopback with no token only gets runHttp's warning; off-loopback is refused.
190
- if (config.transport === "http" && !config.readToken && !LOOPBACK_HOSTS.includes(config.httpHost)) {
191
- say(fail(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`));
192
- process.exit(1);
241
+ if (!claimed) {
242
+ say(fail(`Another wazap keeps taking ${config.dataDir} as this one starts. Run it again.`));
243
+ process.exit(2);
193
244
  }
194
- mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
195
- writeLock(p.lockFile);
196
- process.on("exit", () => releaseLock(p.lockFile));
245
+ process.on("exit", () => {
246
+ removeDaemon(p.daemonFile);
247
+ releaseLock(p.lockFile);
248
+ });
197
249
  const wa = new WhatsAppService(config);
198
- const shutdown = (signal) => {
199
- log(`received ${signal}, shutting down`);
250
+ let stopping = false;
251
+ const shutdown = (reason) => {
252
+ if (stopping)
253
+ return;
254
+ stopping = true;
255
+ log(`received ${reason}, shutting down`);
200
256
  // A wedged socket must not cost the user a kill -9; the lock goes on "exit".
201
257
  setTimeout(() => process.exit(0), 3_000).unref();
202
258
  void wa.stop().finally(() => process.exit(0));
@@ -206,10 +262,31 @@ export async function runServe(config) {
206
262
  // Connecting in the background: MCP startup never waits on WhatsApp, and the
207
263
  // tools answer NOT_LINKED until a session exists.
208
264
  wa.start().catch((err) => logError("whatsapp start", err));
209
- if (config.transport === "http")
210
- await runHttp(wa, config);
211
- else
212
- await runStdio(wa, config);
265
+ const token = config.share ? randomBytes(32).toString("hex") : null;
266
+ // One bucket for the process, not per endpoint: a bridge writing through the
267
+ // loopback endpoint spends from the same allowance as the daemon's own client.
268
+ const limiter = new RateLimiter(config.rateLimitPerMinute);
269
+ if (config.transport === "http") {
270
+ const port = await runHttp(wa, config, limiter, token === null ? undefined : { token, write: true });
271
+ // Off-loopback binds get no sidecar: a bridge on this machine could not reach them.
272
+ if (token !== null && SHAREABLE_HOSTS.includes(config.httpHost)) {
273
+ writeDaemon(p.daemonFile, { pid: process.pid, port, token, version: WAZAP_VERSION });
274
+ }
275
+ return;
276
+ }
277
+ if (token !== null) {
278
+ const port = await startLoopbackEndpoint(wa, config, token, limiter);
279
+ writeDaemon(p.daemonFile, { pid: process.pid, port, token, version: WAZAP_VERSION });
280
+ }
281
+ await runStdio(wa, config, limiter);
282
+ if (token === null)
283
+ return;
284
+ // The loopback endpoint keeps the event loop alive, so stdin EOF no longer ends
285
+ // the process on its own. When the daemon's own client goes away the daemon goes
286
+ // with it, rather than lingering for bridges. Registered after runStdio, so the
287
+ // transport is already reading and "end" fires.
288
+ process.stdin.on("end", () => shutdown("stdin end"));
289
+ process.stdin.on("close", () => shutdown("stdin close"));
213
290
  }
214
291
  export function stepper(total) {
215
292
  let n = 0;
@@ -239,13 +316,11 @@ export async function runLogin(config) {
239
316
  */
240
317
  export async function linkAndSync(config, announce = () => { }) {
241
318
  const p = paths(config.dataDir);
242
- const running = lockHolder(p.lockFile);
319
+ const running = takeSessionLock(p.lockFile);
243
320
  if (running !== null) {
244
321
  say(fail(`wazap is running (pid ${running}). Stop it first (or quit the client that launched it), then run this again.`));
245
322
  process.exit(1);
246
323
  }
247
- mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
248
- writeLock(p.lockFile);
249
324
  const release = () => releaseLock(p.lockFile);
250
325
  const onInterrupt = () => {
251
326
  release();
package/dist/config.js CHANGED
@@ -15,6 +15,7 @@ export function paths(dataDir) {
15
15
  historyDir: join(dataDir, "history"),
16
16
  storeFile: join(dataDir, "store.json"),
17
17
  lockFile: join(dataDir, "server.lock"),
18
+ daemonFile: join(dataDir, "daemon.json"),
18
19
  envFile: join(dataDir, ".env"),
19
20
  qrFile: join(dataDir, "qr.png"),
20
21
  };
@@ -119,6 +120,7 @@ export function parseCli(argv = process.argv.slice(2)) {
119
120
  httpPort: values.port ? asInt(values.port, 8766) : asInt(process.env.WAZAP_PORT, 8766),
120
121
  readToken: (process.env.WAZAP_READ_TOKEN ?? "").trim() || null,
121
122
  writeToken: (process.env.WAZAP_WRITE_TOKEN ?? "").trim() || null,
123
+ share: !asBool(process.env.WAZAP_NO_SHARE, false),
122
124
  rateLimitPerMinute: asInt(process.env.WAZAP_RATE_LIMIT, 20),
123
125
  sources: {
124
126
  // Resolved before dotenv runs, so the data dir's own .env cannot name it.
package/dist/daemon.js ADDED
@@ -0,0 +1,101 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { lockHolder } from "./lock.js";
5
+ function isPositiveInt(value) {
6
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
7
+ }
8
+ /** The sidecar another process left behind, or null if it is missing, corrupt or the wrong shape. */
9
+ export function readDaemon(file) {
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(readFileSync(file, "utf8"));
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ if (typeof parsed !== "object" || parsed === null)
18
+ return null;
19
+ const { pid, port, token, version } = parsed;
20
+ if (!isPositiveInt(pid) || !isPositiveInt(port))
21
+ return null;
22
+ if (typeof token !== "string" || token === "")
23
+ return null;
24
+ if (typeof version !== "string" || version === "")
25
+ return null;
26
+ return { pid, port, token, version };
27
+ }
28
+ export function writeDaemon(file, info) {
29
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
30
+ // Written aside and renamed in: a bridge polling for the sidecar reads either
31
+ // the old record or the new one, never half a token. The mode argument only
32
+ // applies when a file is created, so the chmod covers a leftover temp file
33
+ // from an earlier run keeping looser permissions.
34
+ const temp = `${file}.${process.pid}.tmp`;
35
+ writeFileSync(temp, `${JSON.stringify(info, null, 2)}\n`, { mode: 0o600 });
36
+ chmodSync(temp, 0o600);
37
+ renameSync(temp, file);
38
+ }
39
+ /** Remove the sidecar, but only if it is still ours. */
40
+ export function removeDaemon(file) {
41
+ try {
42
+ if (readDaemon(file)?.pid !== process.pid)
43
+ return;
44
+ unlinkSync(file);
45
+ }
46
+ catch {
47
+ /* already gone */
48
+ }
49
+ }
50
+ /** Liveness of the loopback endpoint recorded in a sidecar. Never throws. */
51
+ export async function daemonHealthy(port, timeoutMs) {
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
54
+ try {
55
+ const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: controller.signal });
56
+ if (!res.ok)
57
+ return false;
58
+ const body = await res.json();
59
+ return typeof body === "object" && body !== null && body.ok === true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+ const ROLE_TIMEOUT_MS = 3_000;
69
+ const ROLE_POLL_MS = 100;
70
+ /**
71
+ * Who we are for this data dir: the process that owns the session, a bridge onto
72
+ * the one that already does, or neither. The lock is re-read every pass because
73
+ * the winner of a simultaneous start needs a moment to bind its port and publish
74
+ * the sidecar, and because a winner that crashes frees the lock mid-loop.
75
+ */
76
+ export async function decideRole(config, p) {
77
+ const deadline = Date.now() + ROLE_TIMEOUT_MS;
78
+ for (;;) {
79
+ const running = lockHolder(p.lockFile);
80
+ if (running === null)
81
+ return { kind: "daemon" };
82
+ // An explicit --http asks for an HTTP server of its own, not a stdio bridge.
83
+ if (config.share === false || config.transport === "http") {
84
+ return {
85
+ kind: "refuse",
86
+ message: `wazap is already running (pid ${running}) using ${config.dataDir}. Stop it first or use --data-dir.`,
87
+ };
88
+ }
89
+ const info = readDaemon(p.daemonFile);
90
+ if (info !== null && info.pid === running && (await daemonHealthy(info.port, 2_000))) {
91
+ return { kind: "bridge", daemon: info };
92
+ }
93
+ if (Date.now() >= deadline) {
94
+ return {
95
+ kind: "refuse",
96
+ message: `wazap is running (pid ${running}) but is not sharing its session (older version?). Stop it and start again.`,
97
+ };
98
+ }
99
+ await sleep(ROLE_POLL_MS);
100
+ }
101
+ }
package/dist/index.js CHANGED
@@ -42,7 +42,7 @@ Options:
42
42
 
43
43
  Environment: WAZAP_DATA_DIR, WAZAP_READ_ONLY, WAZAP_SYNC_FULL_HISTORY, WAZAP_PERSIST_HISTORY,
44
44
  WAZAP_TRANSPORT, WAZAP_HOST, WAZAP_PORT, WAZAP_READ_TOKEN, WAZAP_WRITE_TOKEN, WAZAP_RATE_LIMIT,
45
- WAZAP_NO_UPDATE_CHECK.
45
+ WAZAP_NO_SHARE, WAZAP_NO_UPDATE_CHECK.
46
46
  An optional <data-dir>/.env is loaded if present.`;
47
47
  async function main() {
48
48
  const invocation = parseCli();
package/dist/lock.js CHANGED
@@ -25,9 +25,32 @@ export function lockHolder(lockFile) {
25
25
  return err.code === "EPERM" ? pid : null;
26
26
  }
27
27
  }
28
+ /** Create the lock file, or false if it already existed. */
29
+ function claim(lockFile) {
30
+ try {
31
+ writeFileSync(lockFile, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
32
+ return true;
33
+ }
34
+ catch (err) {
35
+ if (err.code === "EEXIST")
36
+ return false;
37
+ throw err;
38
+ }
39
+ }
40
+ /** Take the lock, or false if another live process holds it. */
28
41
  export function writeLock(lockFile) {
29
42
  mkdirSync(dirname(lockFile), { recursive: true, mode: 0o700 });
30
- writeFileSync(lockFile, `${process.pid}\n`, { mode: 0o600 });
43
+ if (claim(lockFile))
44
+ return true;
45
+ if (lockHolder(lockFile) !== null)
46
+ return false;
47
+ try {
48
+ unlinkSync(lockFile);
49
+ }
50
+ catch {
51
+ /* someone else cleared the stale lock first */
52
+ }
53
+ return claim(lockFile);
31
54
  }
32
55
  /** Remove the lock, but only if it is still ours. */
33
56
  export function releaseLock(lockFile) {
package/dist/server.js CHANGED
@@ -5,7 +5,6 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
5
5
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
6
6
  import express from "express";
7
7
  import { WAZAP_VERSION } from "./config.js";
8
- import { RateLimiter } from "./ratelimit.js";
9
8
  import { registerTools } from "./tools.js";
10
9
  import { log, logError } from "./logger.js";
11
10
  function isAuthorized(header, expected) {
@@ -21,17 +20,14 @@ function buildMcpServer(wa, config, allowWrite, limiter) {
21
20
  registerTools(server, wa, { allowWrite: allowWrite && !config.readOnly, limiter });
22
21
  return server;
23
22
  }
24
- export async function runStdio(wa, config) {
25
- const limiter = new RateLimiter(config.rateLimitPerMinute);
23
+ export async function runStdio(wa, config, limiter) {
26
24
  const server = buildMcpServer(wa, config, true, limiter);
27
25
  const transport = new StdioServerTransport();
28
26
  await server.connect(transport);
29
27
  log("MCP server ready on stdio.");
30
28
  }
31
- export async function runHttp(wa, config) {
32
- // One bucket for the whole process. A per-session bucket would let an HTTP
33
- // client bypass the write limit by reconnecting.
34
- const limiter = new RateLimiter(config.rateLimitPerMinute);
29
+ /** Serve /mcp and /healthz on one address. Resolves with the bound port, so port 0 works. */
30
+ export async function startHttpEndpoint(wa, config, endpoint, limiter) {
35
31
  const app = express();
36
32
  app.use(express.json());
37
33
  app.use((req, res, next) => {
@@ -48,19 +44,17 @@ export async function runHttp(wa, config) {
48
44
  });
49
45
  next();
50
46
  });
51
- if (!config.readToken) {
47
+ if (endpoint.openRead) {
52
48
  log("WARNING: no WAZAP_READ_TOKEN set, the /mcp endpoint is UNAUTHENTICATED. " +
53
49
  "Set WAZAP_READ_TOKEN before exposing this server beyond localhost.");
54
50
  }
55
- // Two tokens: the read token (WAZAP_READ_TOKEN) and the write token
56
- // (WAZAP_WRITE_TOKEN). A write-token request also unlocks the mutating tools,
51
+ // The first credential the bearer token matches decides the session's tools,
57
52
  // so a leaked read token can never message anyone.
58
53
  const requireAuth = (req, res, next) => {
59
54
  const auth = req.headers.authorization;
60
- const writeOk = config.writeToken ? isAuthorized(auth, config.writeToken) : false;
61
- const readOk = !config.readToken || isAuthorized(auth, config.readToken);
62
- if (readOk || writeOk) {
63
- req.mcpWrite = writeOk;
55
+ const credential = endpoint.credentials.find((entry) => isAuthorized(auth, entry.token));
56
+ if (credential || endpoint.openRead) {
57
+ req.mcpWrite = credential?.write === true;
64
58
  next();
65
59
  return;
66
60
  }
@@ -124,10 +118,29 @@ export async function runHttp(wa, config) {
124
118
  app.get("/healthz", (_req, res) => {
125
119
  res.json({ ok: true, status: wa.getStatus().status });
126
120
  });
127
- await new Promise((resolve) => {
128
- app.listen(config.httpPort, config.httpHost, () => {
129
- log(`MCP server (Streamable HTTP) on http://${config.httpHost}:${config.httpPort}/mcp`);
130
- resolve();
121
+ return await new Promise((resolve) => {
122
+ const server = app.listen(endpoint.port, endpoint.host, () => {
123
+ const bound = server.address();
124
+ resolve(typeof bound === "object" && bound !== null ? bound.port : endpoint.port);
131
125
  });
132
126
  });
133
127
  }
128
+ /** The endpoint the user asked for: WAZAP_HOST/WAZAP_PORT and the two configured tokens. */
129
+ export async function runHttp(wa, config, limiter, extra) {
130
+ const credentials = [];
131
+ if (config.readToken)
132
+ credentials.push({ token: config.readToken, write: false });
133
+ if (config.writeToken)
134
+ credentials.push({ token: config.writeToken, write: true });
135
+ if (extra)
136
+ credentials.push(extra);
137
+ const port = await startHttpEndpoint(wa, config, { host: config.httpHost, port: config.httpPort, credentials, openRead: !config.readToken }, limiter);
138
+ log(`MCP server (Streamable HTTP) on http://${config.httpHost}:${port}/mcp`);
139
+ return port;
140
+ }
141
+ /** A private endpoint on an ephemeral loopback port, reachable only with the token. */
142
+ export async function startLoopbackEndpoint(wa, config, token, limiter) {
143
+ const port = await startHttpEndpoint(wa, config, { host: "127.0.0.1", port: 0, credentials: [{ token, write: true }], openRead: false }, limiter);
144
+ log(`sharing this session on 127.0.0.1:${port}`);
145
+ return port;
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wazap-mcp",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, 22 tools, stdio or token-protected HTTP.",
5
5
  "license": "MIT",
6
6
  "author": "Răzvan Girgiz",