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 +62 -4
- package/dist/cli.js +8 -0
- package/dist/config.js +3 -0
- package/dist/doctor.js +19 -0
- package/dist/index.js +2 -1
- package/dist/oauth.js +498 -0
- package/dist/server.js +74 -12
- package/dist/settings.js +3 -1
- package/package.json +3 -2
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
|
|
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
|
|
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.
|
|
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,
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 {
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
57
|
-
req.mcpWrite = credential
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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) =>
|
|
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.
|
|
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
|
|
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"
|