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 +20 -2
- package/dist/bridge.js +64 -0
- package/dist/cli.js +103 -28
- package/dist/config.js +2 -0
- package/dist/daemon.js +101 -0
- package/dist/index.js +1 -1
- package/dist/lock.js +24 -1
- package/dist/server.js +31 -18
- package/package.json +1 -1
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
|
-
|
|
196
|
-
|
|
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 {
|
|
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:
|
|
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}`,
|
|
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",
|
|
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
|
-
|
|
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.`, "
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
245
|
+
process.on("exit", () => {
|
|
246
|
+
removeDaemon(p.daemonFile);
|
|
247
|
+
releaseLock(p.lockFile);
|
|
248
|
+
});
|
|
197
249
|
const wa = new WhatsAppService(config);
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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(
|
|
129
|
-
|
|
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