wazap-mcp 0.9.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/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/auth-state.js +116 -0
- package/dist/banner.js +7 -0
- package/dist/cli.js +372 -0
- package/dist/config.js +138 -0
- package/dist/connect.js +222 -0
- package/dist/doctor.js +114 -0
- package/dist/errors.js +48 -0
- package/dist/ids.js +50 -0
- package/dist/index.js +88 -0
- package/dist/lock.js +42 -0
- package/dist/logger.js +16 -0
- package/dist/messages.js +281 -0
- package/dist/ratelimit.js +35 -0
- package/dist/server.js +133 -0
- package/dist/settings.js +74 -0
- package/dist/tools.js +591 -0
- package/dist/wa-types.js +2 -0
- package/dist/whatsapp.js +1514 -0
- package/package.json +60 -0
package/dist/messages.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message shaping. Pure: no socket, no store, no I/O — everything the store
|
|
3
|
+
* knows (display names, lid→pn) arrives through MessageViewContext.
|
|
4
|
+
*/
|
|
5
|
+
import { getContentType, proto } from "baileys";
|
|
6
|
+
export function protoNumber(value) {
|
|
7
|
+
if (value === null || value === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
if (typeof value === "number")
|
|
10
|
+
return value;
|
|
11
|
+
return typeof value.toNumber === "function" ? value.toNumber() : undefined;
|
|
12
|
+
}
|
|
13
|
+
export function messageTimestampMs(raw) {
|
|
14
|
+
const seconds = protoNumber(raw.messageTimestamp);
|
|
15
|
+
return seconds === undefined ? Date.now() : seconds * 1000;
|
|
16
|
+
}
|
|
17
|
+
/** Stable across restarts, and stable under lid→pn remapping of the chat. */
|
|
18
|
+
export function messageIdFor(key, chatId) {
|
|
19
|
+
return `${key.fromMe ? "true" : "false"}_${chatId}_${key.id ?? ""}`;
|
|
20
|
+
}
|
|
21
|
+
function coords(lat, lng) {
|
|
22
|
+
const a = protoNumber(lat);
|
|
23
|
+
const b = protoNumber(lng);
|
|
24
|
+
return a === undefined || b === undefined ? undefined : `${a.toFixed(2)}, ${b.toFixed(2)}`;
|
|
25
|
+
}
|
|
26
|
+
function pollName(m) {
|
|
27
|
+
const inner = m.pollCreationMessageV4?.message;
|
|
28
|
+
return (m.pollCreationMessage?.name ??
|
|
29
|
+
m.pollCreationMessageV2?.name ??
|
|
30
|
+
m.pollCreationMessageV3?.name ??
|
|
31
|
+
m.pollCreationMessageV5?.name ??
|
|
32
|
+
inner?.pollCreationMessage?.name ??
|
|
33
|
+
undefined);
|
|
34
|
+
}
|
|
35
|
+
const POLL = { type: "poll", tag: "[poll]", detail: pollName };
|
|
36
|
+
function viewOnceInner(m) {
|
|
37
|
+
return (m.viewOnceMessage?.message ?? m.viewOnceMessageV2?.message ?? m.viewOnceMessageV2Extension?.message ?? undefined);
|
|
38
|
+
}
|
|
39
|
+
const VIEW_ONCE = {
|
|
40
|
+
type: "view_once",
|
|
41
|
+
tag: (m) => (viewOnceInner(m)?.videoMessage ? "[view-once video]" : "[view-once photo]"),
|
|
42
|
+
};
|
|
43
|
+
const SYSTEM_TEXT = "[system message]";
|
|
44
|
+
const DELETED_TEXT = "[deleted]";
|
|
45
|
+
function isRevoke(m) {
|
|
46
|
+
return m.protocolMessage?.type === proto.Message.ProtocolMessage.Type.REVOKE;
|
|
47
|
+
}
|
|
48
|
+
const PROTOCOL = {
|
|
49
|
+
type: (m) => (isRevoke(m) ? "deleted" : "system"),
|
|
50
|
+
tag: (m) => (isRevoke(m) ? DELETED_TEXT : SYSTEM_TEXT),
|
|
51
|
+
};
|
|
52
|
+
const SYSTEM = { type: "system", tag: SYSTEM_TEXT };
|
|
53
|
+
const UNKNOWN = { type: "unknown", tag: "[unsupported message]" };
|
|
54
|
+
/**
|
|
55
|
+
* One table drives both messageType and messageText, so the reported type and
|
|
56
|
+
* the placeholder can never disagree.
|
|
57
|
+
*/
|
|
58
|
+
const RULES = {
|
|
59
|
+
conversation: { type: "text", tag: "[text]", text: (m) => m.conversation },
|
|
60
|
+
extendedTextMessage: { type: "text", tag: "[text]", text: (m) => m.extendedTextMessage?.text },
|
|
61
|
+
imageMessage: { type: "image", tag: "[image]", caption: (m) => m.imageMessage?.caption },
|
|
62
|
+
videoMessage: { type: "video", tag: "[video]", caption: (m) => m.videoMessage?.caption },
|
|
63
|
+
ptvMessage: { type: "video", tag: "[video]", caption: (m) => m.ptvMessage?.caption },
|
|
64
|
+
audioMessage: {
|
|
65
|
+
type: (m) => (m.audioMessage?.ptt ? "voice" : "audio"),
|
|
66
|
+
tag: (m) => (m.audioMessage?.ptt ? "[voice message]" : "[audio]"),
|
|
67
|
+
},
|
|
68
|
+
documentMessage: {
|
|
69
|
+
type: "document",
|
|
70
|
+
tag: "[document]",
|
|
71
|
+
caption: (m) => m.documentMessage?.caption,
|
|
72
|
+
detail: (m) => m.documentMessage?.fileName,
|
|
73
|
+
},
|
|
74
|
+
stickerMessage: { type: "sticker", tag: "[sticker]" },
|
|
75
|
+
locationMessage: {
|
|
76
|
+
type: "location",
|
|
77
|
+
tag: "[location]",
|
|
78
|
+
detail: (m) => m.locationMessage?.name ?? coords(m.locationMessage?.degreesLatitude, m.locationMessage?.degreesLongitude),
|
|
79
|
+
},
|
|
80
|
+
liveLocationMessage: {
|
|
81
|
+
type: "location",
|
|
82
|
+
tag: "[location]",
|
|
83
|
+
detail: (m) => m.liveLocationMessage?.caption ??
|
|
84
|
+
coords(m.liveLocationMessage?.degreesLatitude, m.liveLocationMessage?.degreesLongitude),
|
|
85
|
+
},
|
|
86
|
+
contactMessage: { type: "contact", tag: "[contact]", detail: (m) => m.contactMessage?.displayName },
|
|
87
|
+
contactsArrayMessage: { type: "contact", tag: "[contact]", detail: (m) => m.contactsArrayMessage?.displayName },
|
|
88
|
+
pollCreationMessage: POLL,
|
|
89
|
+
pollCreationMessageV2: POLL,
|
|
90
|
+
pollCreationMessageV3: POLL,
|
|
91
|
+
pollCreationMessageV4: POLL,
|
|
92
|
+
pollCreationMessageV5: POLL,
|
|
93
|
+
reactionMessage: { type: "reaction", tag: "[reaction]", detail: (m) => m.reactionMessage?.text },
|
|
94
|
+
viewOnceMessage: VIEW_ONCE,
|
|
95
|
+
viewOnceMessageV2: VIEW_ONCE,
|
|
96
|
+
viewOnceMessageV2Extension: VIEW_ONCE,
|
|
97
|
+
protocolMessage: PROTOCOL,
|
|
98
|
+
senderKeyDistributionMessage: SYSTEM,
|
|
99
|
+
messageContextInfo: SYSTEM,
|
|
100
|
+
};
|
|
101
|
+
const MEDIA_KEYS = [
|
|
102
|
+
"imageMessage",
|
|
103
|
+
"videoMessage",
|
|
104
|
+
"ptvMessage",
|
|
105
|
+
"audioMessage",
|
|
106
|
+
"documentMessage",
|
|
107
|
+
"stickerMessage",
|
|
108
|
+
];
|
|
109
|
+
/** Envelopes that only wrap another message; the inner one is the real content. */
|
|
110
|
+
function unwrapEnvelopes(content) {
|
|
111
|
+
let current = content ?? undefined;
|
|
112
|
+
for (let depth = 0; depth < 5 && current; depth++) {
|
|
113
|
+
const inner = current.deviceSentMessage?.message ??
|
|
114
|
+
current.ephemeralMessage?.message ??
|
|
115
|
+
current.documentWithCaptionMessage?.message;
|
|
116
|
+
if (!inner)
|
|
117
|
+
break;
|
|
118
|
+
current = inner;
|
|
119
|
+
}
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
function ruleFor(content) {
|
|
123
|
+
if (!content)
|
|
124
|
+
return { rule: UNKNOWN, content: {} };
|
|
125
|
+
const key = getContentType(content);
|
|
126
|
+
const rule = key ? RULES[key] : undefined;
|
|
127
|
+
if (rule)
|
|
128
|
+
return { rule, content };
|
|
129
|
+
if (content.messageContextInfo || content.senderKeyDistributionMessage)
|
|
130
|
+
return { rule: SYSTEM, content };
|
|
131
|
+
return { rule: UNKNOWN, content };
|
|
132
|
+
}
|
|
133
|
+
function stubKind(raw) {
|
|
134
|
+
const stub = raw.messageStubType;
|
|
135
|
+
if (stub === null || stub === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
if (stub === proto.WebMessageInfo.StubType.REVOKE)
|
|
138
|
+
return "deleted";
|
|
139
|
+
return stub === proto.WebMessageInfo.StubType.UNKNOWN ? undefined : "system";
|
|
140
|
+
}
|
|
141
|
+
function resolve(value, content) {
|
|
142
|
+
return typeof value === "function" ? value(content) : value;
|
|
143
|
+
}
|
|
144
|
+
export function messageType(raw) {
|
|
145
|
+
const stub = stubKind(raw);
|
|
146
|
+
if (stub === "deleted")
|
|
147
|
+
return "deleted";
|
|
148
|
+
const content = unwrapEnvelopes(raw.message);
|
|
149
|
+
const { rule, content: node } = ruleFor(content);
|
|
150
|
+
if (rule === UNKNOWN && stub)
|
|
151
|
+
return stub;
|
|
152
|
+
return resolve(rule.type, node);
|
|
153
|
+
}
|
|
154
|
+
/** Never empty: media and system messages get a placeholder like "[sticker]". */
|
|
155
|
+
export function messageText(raw) {
|
|
156
|
+
const content = unwrapEnvelopes(raw.message);
|
|
157
|
+
const { rule, content: node } = ruleFor(content);
|
|
158
|
+
if (rule === UNKNOWN) {
|
|
159
|
+
const stub = stubKind(raw);
|
|
160
|
+
if (stub === "deleted")
|
|
161
|
+
return DELETED_TEXT;
|
|
162
|
+
if (stub === "system")
|
|
163
|
+
return SYSTEM_TEXT;
|
|
164
|
+
}
|
|
165
|
+
const text = rule.text?.(node)?.trim();
|
|
166
|
+
if (text)
|
|
167
|
+
return text;
|
|
168
|
+
const tag = resolve(rule.tag, node);
|
|
169
|
+
const caption = rule.caption?.(node)?.trim();
|
|
170
|
+
if (caption)
|
|
171
|
+
return `${tag} ${caption}`;
|
|
172
|
+
const detail = rule.detail?.(node)?.trim();
|
|
173
|
+
return detail ? `${tag} ${detail}` : tag;
|
|
174
|
+
}
|
|
175
|
+
export function mediaInfo(raw) {
|
|
176
|
+
const outer = unwrapEnvelopes(raw.message);
|
|
177
|
+
const content = outer ? (viewOnceInner(outer) ?? outer) : undefined;
|
|
178
|
+
if (!content)
|
|
179
|
+
return undefined;
|
|
180
|
+
for (const key of MEDIA_KEYS) {
|
|
181
|
+
const node = content[key];
|
|
182
|
+
if (!node)
|
|
183
|
+
continue;
|
|
184
|
+
return {
|
|
185
|
+
mime: node.mimetype ?? "application/octet-stream",
|
|
186
|
+
size: protoNumber(node.fileLength),
|
|
187
|
+
filename: node.fileName ?? undefined,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
function contextInfo(raw) {
|
|
193
|
+
const content = unwrapEnvelopes(raw.message);
|
|
194
|
+
if (!content)
|
|
195
|
+
return undefined;
|
|
196
|
+
const key = getContentType(content);
|
|
197
|
+
if (!key)
|
|
198
|
+
return undefined;
|
|
199
|
+
const node = content[key];
|
|
200
|
+
return node?.contextInfo ?? undefined;
|
|
201
|
+
}
|
|
202
|
+
/** "just now", "5m ago", "2h ago", "3d ago" — largest whole unit. */
|
|
203
|
+
export function formatAge(fromMs, nowMs = Date.now()) {
|
|
204
|
+
const elapsed = Math.max(0, nowMs - fromMs);
|
|
205
|
+
const days = Math.floor(elapsed / 86_400_000);
|
|
206
|
+
if (days >= 1)
|
|
207
|
+
return `${days}d ago`;
|
|
208
|
+
const hours = Math.floor(elapsed / 3_600_000);
|
|
209
|
+
if (hours >= 1)
|
|
210
|
+
return `${hours}h ago`;
|
|
211
|
+
const minutes = Math.floor(elapsed / 60_000);
|
|
212
|
+
return minutes >= 1 ? `${minutes}m ago` : "just now";
|
|
213
|
+
}
|
|
214
|
+
/** Local-time ISO 8601 with a numeric offset, e.g. 2026-08-22T21:30:00+03:00. */
|
|
215
|
+
export function isoWithOffset(ms) {
|
|
216
|
+
const at = new Date(ms);
|
|
217
|
+
const pad = (value, width = 2) => String(Math.abs(value)).padStart(width, "0");
|
|
218
|
+
const offsetMinutes = -at.getTimezoneOffset();
|
|
219
|
+
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
220
|
+
const date = `${pad(at.getFullYear(), 4)}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`;
|
|
221
|
+
const time = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
|
|
222
|
+
const offset = `${pad(Math.floor(Math.abs(offsetMinutes) / 60))}:${pad(Math.abs(offsetMinutes) % 60)}`;
|
|
223
|
+
return `${date}T${time}${sign}${offset}`;
|
|
224
|
+
}
|
|
225
|
+
function senderJid(raw, ctx) {
|
|
226
|
+
if (raw.key.fromMe)
|
|
227
|
+
return ctx.ownId;
|
|
228
|
+
const from = raw.key.participant ?? raw.participant ?? raw.key.remoteJid ?? "";
|
|
229
|
+
return from ? ctx.canonical(from) : ctx.ownId;
|
|
230
|
+
}
|
|
231
|
+
function phoneOf(jid) {
|
|
232
|
+
const [user = "", domain] = jid.split("@");
|
|
233
|
+
return domain === "s.whatsapp.net" && /^\d+$/.test(user) ? user : undefined;
|
|
234
|
+
}
|
|
235
|
+
export function buildMessageView(raw, ctx) {
|
|
236
|
+
const timestamp = messageTimestampMs(raw);
|
|
237
|
+
const sender = senderJid(raw, ctx);
|
|
238
|
+
const media = mediaInfo(raw);
|
|
239
|
+
const context = contextInfo(raw);
|
|
240
|
+
const quoted = context?.quotedMessage ? quotedView(context, ctx) : undefined;
|
|
241
|
+
const view = {
|
|
242
|
+
message_id: messageIdFor(raw.key, ctx.chatId),
|
|
243
|
+
chat_id: ctx.chatId,
|
|
244
|
+
from_me: Boolean(raw.key.fromMe),
|
|
245
|
+
sender: {
|
|
246
|
+
id: sender,
|
|
247
|
+
name: ctx.nameFor(sender) || raw.pushName || phoneOf(sender) || sender,
|
|
248
|
+
...(phoneOf(sender) ? { phone: phoneOf(sender) } : {}),
|
|
249
|
+
},
|
|
250
|
+
type: messageType(raw),
|
|
251
|
+
text: messageText(raw),
|
|
252
|
+
timestamp: isoWithOffset(timestamp),
|
|
253
|
+
age: formatAge(timestamp, ctx.now),
|
|
254
|
+
has_media: media !== undefined,
|
|
255
|
+
forwarded: Boolean(context?.isForwarded) || (protoNumber(context?.forwardingScore) ?? 0) > 0,
|
|
256
|
+
edited: ctx.edited,
|
|
257
|
+
};
|
|
258
|
+
if (media)
|
|
259
|
+
view.media = media;
|
|
260
|
+
if (quoted)
|
|
261
|
+
view.quoted = quoted;
|
|
262
|
+
if (ctx.reactions.length > 0)
|
|
263
|
+
view.reactions = ctx.reactions;
|
|
264
|
+
return view;
|
|
265
|
+
}
|
|
266
|
+
function quotedView(context, ctx) {
|
|
267
|
+
if (!context.quotedMessage || !context.stanzaId)
|
|
268
|
+
return undefined;
|
|
269
|
+
const participant = context.participant ? ctx.canonical(context.participant) : ctx.ownId;
|
|
270
|
+
const key = {
|
|
271
|
+
id: context.stanzaId,
|
|
272
|
+
fromMe: participant === ctx.ownId,
|
|
273
|
+
remoteJid: ctx.chatId,
|
|
274
|
+
};
|
|
275
|
+
const inner = { key, message: context.quotedMessage };
|
|
276
|
+
return {
|
|
277
|
+
message_id: messageIdFor(key, ctx.chatId),
|
|
278
|
+
text: messageText(inner),
|
|
279
|
+
sender: participant,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { WazapError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Token bucket over the write tools. An agent that loops on send_message would
|
|
4
|
+
* otherwise burn through WhatsApp's own spam thresholds and get the number
|
|
5
|
+
* banned, which is not recoverable from this side.
|
|
6
|
+
*/
|
|
7
|
+
export class RateLimiter {
|
|
8
|
+
perMinute;
|
|
9
|
+
now;
|
|
10
|
+
tokens;
|
|
11
|
+
last;
|
|
12
|
+
constructor(perMinute, now = Date.now) {
|
|
13
|
+
this.perMinute = perMinute;
|
|
14
|
+
this.now = now;
|
|
15
|
+
this.tokens = perMinute;
|
|
16
|
+
this.last = now();
|
|
17
|
+
}
|
|
18
|
+
get enabled() {
|
|
19
|
+
return this.perMinute > 0;
|
|
20
|
+
}
|
|
21
|
+
/** Consume one token, or throw RATE_LIMITED with the wait in the fix. */
|
|
22
|
+
take() {
|
|
23
|
+
if (!this.enabled)
|
|
24
|
+
return;
|
|
25
|
+
const now = this.now();
|
|
26
|
+
this.tokens = Math.min(this.perMinute, this.tokens + ((now - this.last) * this.perMinute) / 60_000);
|
|
27
|
+
this.last = now;
|
|
28
|
+
if (this.tokens >= 1) {
|
|
29
|
+
this.tokens -= 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const seconds = Math.max(1, Math.ceil(((1 - this.tokens) * 60_000) / this.perMinute / 1000));
|
|
33
|
+
throw new WazapError("RATE_LIMITED", `Write rate limit reached (${this.perMinute}/minute).`, `Wait ${seconds} seconds`);
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import express from "express";
|
|
7
|
+
import { WAZAP_VERSION } from "./config.js";
|
|
8
|
+
import { RateLimiter } from "./ratelimit.js";
|
|
9
|
+
import { registerTools } from "./tools.js";
|
|
10
|
+
import { log, logError } from "./logger.js";
|
|
11
|
+
function isAuthorized(header, expected) {
|
|
12
|
+
const prefix = "Bearer ";
|
|
13
|
+
if (!header || !header.startsWith(prefix))
|
|
14
|
+
return false;
|
|
15
|
+
const got = Buffer.from(header.slice(prefix.length).trim());
|
|
16
|
+
const want = Buffer.from(expected);
|
|
17
|
+
return got.length === want.length && timingSafeEqual(got, want);
|
|
18
|
+
}
|
|
19
|
+
function buildMcpServer(wa, config, allowWrite, limiter) {
|
|
20
|
+
const server = new McpServer({ name: "wazap", version: WAZAP_VERSION });
|
|
21
|
+
registerTools(server, wa, { allowWrite: allowWrite && !config.readOnly, limiter });
|
|
22
|
+
return server;
|
|
23
|
+
}
|
|
24
|
+
export async function runStdio(wa, config) {
|
|
25
|
+
const limiter = new RateLimiter(config.rateLimitPerMinute);
|
|
26
|
+
const server = buildMcpServer(wa, config, true, limiter);
|
|
27
|
+
const transport = new StdioServerTransport();
|
|
28
|
+
await server.connect(transport);
|
|
29
|
+
log("MCP server ready on stdio.");
|
|
30
|
+
}
|
|
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);
|
|
35
|
+
const app = express();
|
|
36
|
+
app.use(express.json());
|
|
37
|
+
app.use((req, res, next) => {
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
const rpc = (req.body && typeof req.body === "object" ? req.body.method : undefined) ?? "-";
|
|
40
|
+
const hasAuth = req.headers.authorization ? "auth" : "noauth";
|
|
41
|
+
res.on("finish", () => {
|
|
42
|
+
log(`HTTP ${req.method} ${req.url} rpc=${rpc} ${hasAuth} accept="${req.headers.accept ?? ""}" -> ${res.statusCode} (${Date.now() - start}ms)`);
|
|
43
|
+
});
|
|
44
|
+
res.on("close", () => {
|
|
45
|
+
if (!res.writableEnded) {
|
|
46
|
+
log(`HTTP ${req.method} ${req.url} rpc=${rpc} -> client closed before response (${Date.now() - start}ms)`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
next();
|
|
50
|
+
});
|
|
51
|
+
if (!config.readToken) {
|
|
52
|
+
log("WARNING: no WAZAP_READ_TOKEN set, the /mcp endpoint is UNAUTHENTICATED. " +
|
|
53
|
+
"Set WAZAP_READ_TOKEN before exposing this server beyond localhost.");
|
|
54
|
+
}
|
|
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,
|
|
57
|
+
// so a leaked read token can never message anyone.
|
|
58
|
+
const requireAuth = (req, res, next) => {
|
|
59
|
+
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;
|
|
64
|
+
next();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
res.status(401).json({
|
|
68
|
+
jsonrpc: "2.0",
|
|
69
|
+
error: { code: -32001, message: "Unauthorized: missing or invalid bearer token" },
|
|
70
|
+
id: null,
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
// Session-based Streamable HTTP (the SDK's canonical pattern). It serves the
|
|
74
|
+
// GET SSE stream and DELETE that full MCP clients open; a stateless server
|
|
75
|
+
// 404s the GET and makes such clients hang until they time out.
|
|
76
|
+
const transports = new Map();
|
|
77
|
+
const handleMcp = async (req, res) => {
|
|
78
|
+
try {
|
|
79
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
80
|
+
let transport = typeof sessionId === "string" ? transports.get(sessionId) : undefined;
|
|
81
|
+
if (!transport && req.method === "POST" && isInitializeRequest(req.body)) {
|
|
82
|
+
const newTransport = new StreamableHTTPServerTransport({
|
|
83
|
+
sessionIdGenerator: () => randomUUID(),
|
|
84
|
+
onsessioninitialized: (sid) => {
|
|
85
|
+
transports.set(sid, newTransport);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
newTransport.onclose = () => {
|
|
89
|
+
const sid = newTransport.sessionId;
|
|
90
|
+
if (sid)
|
|
91
|
+
transports.delete(sid);
|
|
92
|
+
};
|
|
93
|
+
// The session's tools are fixed at init by the token it authenticated with.
|
|
94
|
+
const server = buildMcpServer(wa, config, req.mcpWrite === true, limiter);
|
|
95
|
+
await server.connect(newTransport);
|
|
96
|
+
transport = newTransport;
|
|
97
|
+
}
|
|
98
|
+
if (!transport) {
|
|
99
|
+
res.status(400).json({
|
|
100
|
+
jsonrpc: "2.0",
|
|
101
|
+
error: { code: -32000, message: "Bad Request: no valid session ID (send initialize first)" },
|
|
102
|
+
id: null,
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await transport.handleRequest(req, res, req.body);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
logError("http request", err);
|
|
110
|
+
if (!res.headersSent) {
|
|
111
|
+
res.status(500).json({
|
|
112
|
+
jsonrpc: "2.0",
|
|
113
|
+
error: { code: -32603, message: "Internal server error" },
|
|
114
|
+
id: null,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
app.post("/mcp", requireAuth, handleMcp);
|
|
120
|
+
app.get("/mcp", requireAuth, handleMcp);
|
|
121
|
+
app.delete("/mcp", requireAuth, handleMcp);
|
|
122
|
+
// Unauthenticated, so it carries liveness only; the account and data dir
|
|
123
|
+
// stay behind the token in get_status.
|
|
124
|
+
app.get("/healthz", (_req, res) => {
|
|
125
|
+
res.json({ ok: true, status: wa.getStatus().status });
|
|
126
|
+
});
|
|
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();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { paths } from "./config.js";
|
|
4
|
+
import { WazapError } from "./errors.js";
|
|
5
|
+
import { lockHolder } from "./lock.js";
|
|
6
|
+
import { say } from "./logger.js";
|
|
7
|
+
/** Replace `KEY=` in place, keeping every other line, or append it. */
|
|
8
|
+
export function setEnvSetting(envFile, key, value) {
|
|
9
|
+
let text = "";
|
|
10
|
+
try {
|
|
11
|
+
text = readFileSync(envFile, "utf8");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
/* a data dir without an .env yet */
|
|
15
|
+
}
|
|
16
|
+
const line = `${key}=${value}`;
|
|
17
|
+
const lines = text === "" ? [] : text.split("\n");
|
|
18
|
+
// dotenv trims around `=` and applies last-wins, so every spelling of the key
|
|
19
|
+
// has to go: leaving a later duplicate behind would silently outrank the edit.
|
|
20
|
+
const assignment = new RegExp(`^\\s*(export\\s+)?${key}\\s*=`);
|
|
21
|
+
const hits = lines.flatMap((existing, index) => (assignment.test(existing) ? [index] : []));
|
|
22
|
+
if (hits.length === 0) {
|
|
23
|
+
const body = text.trimEnd();
|
|
24
|
+
text = body === "" ? `${line}\n` : `${body}\n${line}\n`;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
lines[hits[0]] = line;
|
|
28
|
+
text = lines.filter((_, index) => index === hits[0] || !hits.includes(index)).join("\n");
|
|
29
|
+
if (!text.endsWith("\n"))
|
|
30
|
+
text += "\n";
|
|
31
|
+
}
|
|
32
|
+
mkdirSync(dirname(envFile), { recursive: true, mode: 0o700 });
|
|
33
|
+
writeFileSync(envFile, text, { mode: 0o600 });
|
|
34
|
+
}
|
|
35
|
+
const SETTINGS = [
|
|
36
|
+
{ label: "data dir", source: "dataDir", value: (config) => config.dataDir },
|
|
37
|
+
{ label: "writes", source: "readOnly", value: (config) => (config.readOnly ? "off" : "on") },
|
|
38
|
+
{
|
|
39
|
+
label: "transport",
|
|
40
|
+
source: "transport",
|
|
41
|
+
value: (config) => (config.transport === "http" ? `http ${config.httpHost}:${config.httpPort}` : "stdio"),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
label: "rate limit",
|
|
45
|
+
source: "rateLimit",
|
|
46
|
+
value: (config) => (config.rateLimitPerMinute === 0 ? "off" : `${config.rateLimitPerMinute} writes/minute`),
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
export function runConfig(config) {
|
|
50
|
+
if (config.args.length === 0) {
|
|
51
|
+
for (const row of SETTINGS)
|
|
52
|
+
say(`${row.label}: ${row.value(config)} (${config.sources[row.source]})`);
|
|
53
|
+
say("");
|
|
54
|
+
say("Change writes with `wazap config writes on|off`.");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const [setting, value] = config.args;
|
|
58
|
+
if (setting !== "writes" || (value !== "on" && value !== "off")) {
|
|
59
|
+
throw new WazapError("INVALID_ID", `Cannot set "${setting} ${value}".`, "Run `wazap config writes on|off`");
|
|
60
|
+
}
|
|
61
|
+
applyWrites(config, value === "on");
|
|
62
|
+
}
|
|
63
|
+
/** Persist the writes answer, then say what is now true and how to change it. */
|
|
64
|
+
export function applyWrites(config, allowWrites) {
|
|
65
|
+
const p = paths(config.dataDir);
|
|
66
|
+
setEnvSetting(p.envFile, "WAZAP_READ_ONLY", allowWrites ? "0" : "1");
|
|
67
|
+
say(allowWrites
|
|
68
|
+
? `writes: on — the agent can send messages, react and manage chats. Turn it off with \`wazap config writes off\`.`
|
|
69
|
+
: `writes: off — the agent can only read. Turn it on with \`wazap config writes on\`.`);
|
|
70
|
+
say(`Stored in ${p.envFile}.`);
|
|
71
|
+
const running = lockHolder(p.lockFile);
|
|
72
|
+
if (running !== null)
|
|
73
|
+
say(`A server is running (pid ${running}); restart it for this to apply.`);
|
|
74
|
+
}
|