@ardennguyen/zalo-agent-cli 1.0.7
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/DISCLAIMER.md +27 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +64 -0
- package/src/cli.test.js +98 -0
- package/src/commands/account.js +178 -0
- package/src/commands/auto-reply.js +83 -0
- package/src/commands/catalog.js +143 -0
- package/src/commands/conv.js +264 -0
- package/src/commands/friend.js +384 -0
- package/src/commands/group.js +606 -0
- package/src/commands/label.js +39 -0
- package/src/commands/listen.js +293 -0
- package/src/commands/login.js +165 -0
- package/src/commands/mcp.js +219 -0
- package/src/commands/msg.js +608 -0
- package/src/commands/oa-init.js +503 -0
- package/src/commands/oa-listen.js +215 -0
- package/src/commands/oa.js +657 -0
- package/src/commands/poll.js +159 -0
- package/src/commands/profile.js +274 -0
- package/src/commands/quick-msg.js +55 -0
- package/src/commands/reminder.js +195 -0
- package/src/core/accounts.js +93 -0
- package/src/core/credentials.js +63 -0
- package/src/core/oa-client.js +407 -0
- package/src/core/zalo-client.js +201 -0
- package/src/index.js +95 -0
- package/src/mcp/mcp-config.js +81 -0
- package/src/mcp/mcp-http-transport.js +84 -0
- package/src/mcp/mcp-server.js +40 -0
- package/src/mcp/mcp-tools.js +353 -0
- package/src/mcp/media-downloader.js +191 -0
- package/src/mcp/media-downloader.test.js +45 -0
- package/src/mcp/message-buffer.js +127 -0
- package/src/mcp/message-buffer.test.js +308 -0
- package/src/mcp/notifier.js +141 -0
- package/src/mcp/notifier.test.js +269 -0
- package/src/mcp/thread-filter.js +79 -0
- package/src/mcp/thread-filter.test.js +206 -0
- package/src/mcp/thread-name-cache.js +162 -0
- package/src/mcp/thread-name-cache.test.js +139 -0
- package/src/utils/bank-helpers.js +233 -0
- package/src/utils/bank-helpers.test.js +66 -0
- package/src/utils/extract-message-text.js +21 -0
- package/src/utils/output.js +21 -0
- package/src/utils/proxy-helpers.js +14 -0
- package/src/utils/proxy-helpers.test.js +36 -0
- package/src/utils/qr-display.js +91 -0
- package/src/utils/qr-display.test.js +97 -0
- package/src/utils/qr-http-server.js +190 -0
- package/src/utils/update-check.js +56 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zalo client wrapper — direct zca-js API calls with proxy support.
|
|
3
|
+
* Manages a single Zalo instance per process. Swap on account switch.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import { Zalo, LoginQRCallbackEventType } from "zca-js";
|
|
8
|
+
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
9
|
+
import { ProxyAgent } from "undici";
|
|
10
|
+
import { getActive } from "./accounts.js";
|
|
11
|
+
import { loadCredentials } from "./credentials.js";
|
|
12
|
+
import { info } from "../utils/output.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read image dimensions from file header bytes (PNG, JPEG, GIF).
|
|
16
|
+
* Returns { width, height, size } or null on failure.
|
|
17
|
+
*/
|
|
18
|
+
async function readImageMetadata(filePath) {
|
|
19
|
+
const stat = await fs.promises.stat(filePath);
|
|
20
|
+
const buf = Buffer.alloc(32);
|
|
21
|
+
const fh = await fs.promises.open(filePath, "r");
|
|
22
|
+
try {
|
|
23
|
+
await fh.read(buf, 0, 32, 0);
|
|
24
|
+
} finally {
|
|
25
|
+
await fh.close();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let width = 0;
|
|
29
|
+
let height = 0;
|
|
30
|
+
|
|
31
|
+
// PNG: bytes 0-3 = 0x89504E47, width at 16, height at 20 (big-endian)
|
|
32
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
|
33
|
+
width = buf.readUInt32BE(16);
|
|
34
|
+
height = buf.readUInt32BE(20);
|
|
35
|
+
}
|
|
36
|
+
// GIF: "GIF87a" or "GIF89a", width at 6, height at 8 (little-endian)
|
|
37
|
+
else if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) {
|
|
38
|
+
width = buf.readUInt16LE(6);
|
|
39
|
+
height = buf.readUInt16LE(8);
|
|
40
|
+
}
|
|
41
|
+
// JPEG: 0xFFD8 — scan segments via file handle to avoid loading entire file
|
|
42
|
+
else if (buf[0] === 0xff && buf[1] === 0xd8) {
|
|
43
|
+
const jfh = await fs.promises.open(filePath, "r");
|
|
44
|
+
try {
|
|
45
|
+
const seg = Buffer.alloc(9); // enough for marker(2) + length(2) + precision(1) + h(2) + w(2)
|
|
46
|
+
let pos = 2; // skip SOI
|
|
47
|
+
while (pos < stat.size - 9) {
|
|
48
|
+
const { bytesRead } = await jfh.read(seg, 0, 4, pos);
|
|
49
|
+
if (bytesRead < 4 || seg[0] !== 0xff) break;
|
|
50
|
+
const marker = seg[1];
|
|
51
|
+
if (
|
|
52
|
+
(marker >= 0xc0 && marker <= 0xc3) ||
|
|
53
|
+
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
54
|
+
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
55
|
+
(marker >= 0xcd && marker <= 0xcf)
|
|
56
|
+
) {
|
|
57
|
+
// Read 5 more bytes: segment length(2) + precision(1) + height(2) + width(2)
|
|
58
|
+
await jfh.read(seg, 0, 7, pos + 2);
|
|
59
|
+
height = seg.readUInt16BE(3);
|
|
60
|
+
width = seg.readUInt16BE(5);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
const segLen = seg.readUInt16BE(2);
|
|
64
|
+
pos += 2 + segLen;
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
await jfh.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (width === 0 || height === 0) return null;
|
|
72
|
+
return { width, height, size: stat.size };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let _api = null;
|
|
76
|
+
let _ownId = null;
|
|
77
|
+
|
|
78
|
+
/** Get the current API instance or throw. */
|
|
79
|
+
export function getApi() {
|
|
80
|
+
if (!_api) throw new Error("Not logged in. Run: zalo-agent login");
|
|
81
|
+
return _api;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Get current owner ID. */
|
|
85
|
+
export function getOwnId() {
|
|
86
|
+
return _ownId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Check if logged in. */
|
|
90
|
+
export function isLoggedIn() {
|
|
91
|
+
return _api !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a proxy-aware fetch that uses undici ProxyAgent dispatcher.
|
|
96
|
+
* Native Node.js fetch ignores the `agent` option — must use `dispatcher`.
|
|
97
|
+
*/
|
|
98
|
+
function createProxyFetch(proxyUrl) {
|
|
99
|
+
const dispatcher = new ProxyAgent(proxyUrl);
|
|
100
|
+
return (url, init = {}) => fetch(url, { ...init, dispatcher });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Create a Zalo instance with optional proxy. Suppress logs in JSON mode. */
|
|
104
|
+
function createZalo(proxyUrl) {
|
|
105
|
+
const opts = {
|
|
106
|
+
// Suppress zca-js internal INFO logs when --json to keep stdout clean
|
|
107
|
+
logging: !process.env.ZALO_JSON_MODE,
|
|
108
|
+
imageMetadataGetter: readImageMetadata,
|
|
109
|
+
};
|
|
110
|
+
if (proxyUrl) {
|
|
111
|
+
// HttpsProxyAgent for WebSocket (ws lib), ProxyAgent dispatcher for HTTP fetch
|
|
112
|
+
opts.agent = new HttpsProxyAgent(proxyUrl);
|
|
113
|
+
opts.polyfill = createProxyFetch(proxyUrl);
|
|
114
|
+
}
|
|
115
|
+
return new Zalo(opts);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Set the active API + ownId (used after login). */
|
|
119
|
+
function setSession(api, ownId) {
|
|
120
|
+
_api = api;
|
|
121
|
+
_ownId = ownId;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Clear current session. */
|
|
125
|
+
export function clearSession() {
|
|
126
|
+
_api = null;
|
|
127
|
+
_ownId = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Login with saved credentials + proxy.
|
|
132
|
+
* @param {object} creds - {imei, cookie, userAgent, language?}
|
|
133
|
+
* @param {string|null} proxyUrl
|
|
134
|
+
* @returns {object} - {api, ownId}
|
|
135
|
+
*/
|
|
136
|
+
export async function loginWithCredentials(creds, proxyUrl = null) {
|
|
137
|
+
const zalo = createZalo(proxyUrl);
|
|
138
|
+
const api = await zalo.login(creds);
|
|
139
|
+
const ownId = api.getOwnId?.() || null;
|
|
140
|
+
setSession(api, ownId);
|
|
141
|
+
return { api, ownId };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Login via QR code with optional proxy.
|
|
146
|
+
* @param {string|null} proxyUrl
|
|
147
|
+
* @param {function} onQrGenerated - callback(qrData) when QR is ready
|
|
148
|
+
* @returns {object} - {api, ownId}
|
|
149
|
+
*/
|
|
150
|
+
export async function loginWithQR(proxyUrl = null, onQrGenerated = null) {
|
|
151
|
+
const zalo = createZalo(proxyUrl);
|
|
152
|
+
|
|
153
|
+
const api = await zalo.loginQR(null, (event) => {
|
|
154
|
+
if (event.type === LoginQRCallbackEventType.QRCodeGenerated && onQrGenerated) {
|
|
155
|
+
onQrGenerated(event);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const ownId = api.getOwnId?.() || null;
|
|
160
|
+
setSession(api, ownId);
|
|
161
|
+
return { api, ownId };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Extract credentials from current session for saving.
|
|
166
|
+
* @returns {object} - {imei, cookie, userAgent, language}
|
|
167
|
+
*/
|
|
168
|
+
export function extractCredentials() {
|
|
169
|
+
const api = getApi();
|
|
170
|
+
const ctx = api.getContext();
|
|
171
|
+
return {
|
|
172
|
+
imei: ctx.imei,
|
|
173
|
+
cookie: ctx.cookie,
|
|
174
|
+
userAgent: ctx.userAgent,
|
|
175
|
+
language: ctx.language,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Auto-login using active account from registry.
|
|
181
|
+
* Called before commands that need authentication.
|
|
182
|
+
* @param {boolean} jsonMode - suppress output in JSON mode
|
|
183
|
+
*/
|
|
184
|
+
export async function autoLogin(jsonMode = false) {
|
|
185
|
+
if (_api) return; // Already logged in
|
|
186
|
+
|
|
187
|
+
const active = getActive();
|
|
188
|
+
if (!active) return;
|
|
189
|
+
|
|
190
|
+
const creds = loadCredentials(active.ownId);
|
|
191
|
+
if (!creds) return;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
await loginWithCredentials(creds, active.proxy || null);
|
|
195
|
+
if (!jsonMode) {
|
|
196
|
+
info(`Auto-login: ${active.name || active.ownId}`);
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// Silent failure — user can login manually
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* zalo-agent-cli — CLI for Zalo automation with multi-account + proxy support.
|
|
5
|
+
* Entry point: registers all command groups via Commander.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { Command } from "commander";
|
|
12
|
+
import { registerLoginCommands } from "./commands/login.js";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
|
|
16
|
+
import { registerMsgCommands } from "./commands/msg.js";
|
|
17
|
+
import { registerFriendCommands } from "./commands/friend.js";
|
|
18
|
+
import { registerGroupCommands } from "./commands/group.js";
|
|
19
|
+
import { registerConvCommands } from "./commands/conv.js";
|
|
20
|
+
import { registerAccountCommands } from "./commands/account.js";
|
|
21
|
+
import { registerProfileCommands } from "./commands/profile.js";
|
|
22
|
+
import { registerPollCommands } from "./commands/poll.js";
|
|
23
|
+
import { registerReminderCommands } from "./commands/reminder.js";
|
|
24
|
+
import { registerAutoReplyCommands } from "./commands/auto-reply.js";
|
|
25
|
+
import { registerQuickMsgCommands } from "./commands/quick-msg.js";
|
|
26
|
+
import { registerLabelCommands } from "./commands/label.js";
|
|
27
|
+
import { registerCatalogCommands } from "./commands/catalog.js";
|
|
28
|
+
import { registerListenCommand } from "./commands/listen.js";
|
|
29
|
+
import { registerOACommands } from "./commands/oa.js";
|
|
30
|
+
import { registerMCPCommands } from "./commands/mcp.js";
|
|
31
|
+
import { autoLogin } from "./core/zalo-client.js";
|
|
32
|
+
import { checkForUpdates, selfUpdate } from "./utils/update-check.js";
|
|
33
|
+
import { success, error, warning } from "./utils/output.js";
|
|
34
|
+
|
|
35
|
+
const DISCLAIMER =
|
|
36
|
+
"This tool uses unofficial Zalo APIs (zca-js) — your account may be banned. Use at your own risk. | Tool này dùng API Zalo không chính thức (zca-js) — account có thể bị ban. Tự chịu trách nhiệm.";
|
|
37
|
+
|
|
38
|
+
const program = new Command();
|
|
39
|
+
|
|
40
|
+
program
|
|
41
|
+
.name("zalo-agent")
|
|
42
|
+
.description("CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments")
|
|
43
|
+
.version(pkg.version)
|
|
44
|
+
.option("--json", "Output results as JSON (machine-readable)")
|
|
45
|
+
.hook("preAction", async (thisCommand) => {
|
|
46
|
+
const cmdName = thisCommand.args?.[0] || thisCommand.name();
|
|
47
|
+
// Suppress zca-js internal logs in JSON mode to keep stdout clean for piping
|
|
48
|
+
if (program.opts().json || cmdName === "mcp") {
|
|
49
|
+
// Suppress zca-js stdout logs: JSON mode needs clean output, MCP uses stdout as transport
|
|
50
|
+
process.env.ZALO_JSON_MODE = "1";
|
|
51
|
+
} else if (cmdName !== "oa") {
|
|
52
|
+
// OA commands use official Zalo API — no disclaimer needed
|
|
53
|
+
warning(DISCLAIMER);
|
|
54
|
+
console.log();
|
|
55
|
+
}
|
|
56
|
+
// Auto-login before any command that needs it (skip for login/account/oa commands)
|
|
57
|
+
const skipAutoLogin = ["login", "account", "help", "version", "update", "oa", "mcp"].includes(cmdName);
|
|
58
|
+
if (!skipAutoLogin) {
|
|
59
|
+
await autoLogin(program.opts().json);
|
|
60
|
+
}
|
|
61
|
+
// Non-blocking update check (skip for update command itself)
|
|
62
|
+
if (cmdName !== "update") {
|
|
63
|
+
checkForUpdates(pkg.version, program.opts().json);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Self-update command
|
|
68
|
+
program
|
|
69
|
+
.command("update")
|
|
70
|
+
.description("Update zalo-agent-cli to the latest version")
|
|
71
|
+
.action(() => {
|
|
72
|
+
const ok = selfUpdate();
|
|
73
|
+
if (ok) success(`Updated to latest version`);
|
|
74
|
+
else error("Update failed. Try manually: npm install -g @ardennguyen/zalo-agent-cli@latest");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Register all command groups
|
|
78
|
+
registerLoginCommands(program);
|
|
79
|
+
registerMsgCommands(program);
|
|
80
|
+
registerFriendCommands(program);
|
|
81
|
+
registerGroupCommands(program);
|
|
82
|
+
registerConvCommands(program);
|
|
83
|
+
registerAccountCommands(program);
|
|
84
|
+
registerProfileCommands(program);
|
|
85
|
+
registerPollCommands(program);
|
|
86
|
+
registerReminderCommands(program);
|
|
87
|
+
registerAutoReplyCommands(program);
|
|
88
|
+
registerQuickMsgCommands(program);
|
|
89
|
+
registerLabelCommands(program);
|
|
90
|
+
registerCatalogCommands(program);
|
|
91
|
+
registerListenCommand(program);
|
|
92
|
+
registerOACommands(program);
|
|
93
|
+
registerMCPCommands(program);
|
|
94
|
+
|
|
95
|
+
program.parse();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load/save MCP-specific config from ~/.zalo-agent-cli/mcp-config.json.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { CONFIG_DIR } from "../core/credentials.js";
|
|
8
|
+
|
|
9
|
+
const MCP_CONFIG_FILE = join(CONFIG_DIR, "mcp-config.json");
|
|
10
|
+
|
|
11
|
+
/** Default MCP config — sensible defaults for local development */
|
|
12
|
+
export function getDefaultConfig() {
|
|
13
|
+
return {
|
|
14
|
+
watchThreads: ["dm:*", "group:*"],
|
|
15
|
+
mode: "manual",
|
|
16
|
+
triggerKeywords: ["@bot"],
|
|
17
|
+
notify: {
|
|
18
|
+
enabled: false,
|
|
19
|
+
thread: null,
|
|
20
|
+
on: ["dm"],
|
|
21
|
+
cooldown: "5m",
|
|
22
|
+
},
|
|
23
|
+
limits: {
|
|
24
|
+
maxMessagesPerPoll: 20,
|
|
25
|
+
autoDigestThreshold: 50,
|
|
26
|
+
bufferMaxAge: "2h",
|
|
27
|
+
bufferMaxSize: 500,
|
|
28
|
+
},
|
|
29
|
+
media: {
|
|
30
|
+
downloadDir: null, // default: ~/.zalo-agent-cli/media/
|
|
31
|
+
autoOpen: true,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load MCP config from disk, merged with defaults.
|
|
38
|
+
* @returns {object} MCP config
|
|
39
|
+
*/
|
|
40
|
+
export function loadMCPConfig() {
|
|
41
|
+
const defaults = getDefaultConfig();
|
|
42
|
+
try {
|
|
43
|
+
const raw = readFileSync(MCP_CONFIG_FILE, "utf-8");
|
|
44
|
+
const saved = JSON.parse(raw);
|
|
45
|
+
// Shallow merge: saved values override defaults
|
|
46
|
+
return {
|
|
47
|
+
...defaults,
|
|
48
|
+
...saved,
|
|
49
|
+
notify: { ...defaults.notify, ...saved.notify },
|
|
50
|
+
limits: { ...defaults.limits, ...saved.limits },
|
|
51
|
+
media: { ...defaults.media, ...saved.media },
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
// File doesn't exist or invalid JSON — use defaults
|
|
55
|
+
return defaults;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Save MCP config to disk.
|
|
61
|
+
* @param {object} config
|
|
62
|
+
*/
|
|
63
|
+
export function saveMCPConfig(config) {
|
|
64
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
65
|
+
writeFileSync(MCP_CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse duration string (e.g. "2h", "5m", "30s") to milliseconds.
|
|
70
|
+
* @param {string|number} duration
|
|
71
|
+
* @returns {number} Milliseconds
|
|
72
|
+
*/
|
|
73
|
+
export function parseDuration(duration) {
|
|
74
|
+
if (typeof duration === "number") return duration;
|
|
75
|
+
const match = String(duration).match(/^(\d+)\s*(h|m|s|ms)?$/i);
|
|
76
|
+
if (!match) return 0;
|
|
77
|
+
const value = parseInt(match[1], 10);
|
|
78
|
+
const unit = (match[2] || "ms").toLowerCase();
|
|
79
|
+
const multipliers = { h: 3600000, m: 60000, s: 1000, ms: 1 };
|
|
80
|
+
return value * (multipliers[unit] || 1);
|
|
81
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express server wrapping MCP StreamableHTTP transport with bearer token auth.
|
|
3
|
+
* Uses stateless mode — each POST /mcp request creates a fresh server+transport.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import express from "express";
|
|
7
|
+
import { timingSafeEqual } from "crypto";
|
|
8
|
+
import { readFileSync } from "fs";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import { dirname, join } from "path";
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create HTTP MCP server with optional bearer token auth.
|
|
19
|
+
* @param {Function} registerToolsFn - Registers tools on a McpServer instance
|
|
20
|
+
* @param {object} deps - { api, buffer, filter, config }
|
|
21
|
+
* @param {number} port
|
|
22
|
+
* @param {string|null} authToken - Bearer token (null = no auth)
|
|
23
|
+
* @returns {import("http").Server}
|
|
24
|
+
*/
|
|
25
|
+
export function createHTTPServer(registerToolsFn, deps, port, authToken, host = "127.0.0.1") {
|
|
26
|
+
const app = express();
|
|
27
|
+
app.use(express.json());
|
|
28
|
+
|
|
29
|
+
// Auth middleware — skips /health
|
|
30
|
+
if (authToken) {
|
|
31
|
+
app.use((req, res, next) => {
|
|
32
|
+
if (req.path === "/health") return next();
|
|
33
|
+
const token = req.headers.authorization?.slice(7) || "";
|
|
34
|
+
const expected = Buffer.from(authToken, "utf8");
|
|
35
|
+
const received = Buffer.from(token, "utf8");
|
|
36
|
+
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
|
|
37
|
+
return res.status(401).json({ error: "Unauthorized" });
|
|
38
|
+
}
|
|
39
|
+
next();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// MCP endpoint — stateless, fresh server+transport per request
|
|
44
|
+
app.post("/mcp", async (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
const server = new McpServer({ name: "zalo-agent", version: pkg.version });
|
|
47
|
+
registerToolsFn(server, deps.api, deps.buffer, deps.filter, deps.config, deps.nameCache);
|
|
48
|
+
|
|
49
|
+
const transport = new StreamableHTTPServerTransport({
|
|
50
|
+
sessionIdGenerator: undefined, // stateless mode
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
res.on("close", () => {
|
|
54
|
+
transport.close();
|
|
55
|
+
server.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await server.connect(transport);
|
|
59
|
+
await transport.handleRequest(req, res, req.body);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error("MCP HTTP error:", err);
|
|
62
|
+
if (!res.headersSent) {
|
|
63
|
+
res.status(500).json({
|
|
64
|
+
jsonrpc: "2.0",
|
|
65
|
+
error: { code: -32603, message: "Internal server error" },
|
|
66
|
+
id: null,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Health check — no auth required
|
|
73
|
+
app.get("/health", (req, res) => {
|
|
74
|
+
res.json({
|
|
75
|
+
status: "ok",
|
|
76
|
+
uptime: Math.floor(process.uptime()),
|
|
77
|
+
threads: deps.buffer.getStats().length,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return app.listen(port, host, () => {
|
|
82
|
+
console.error(`MCP HTTP server listening on ${host}:${port}`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server setup with stdio transport.
|
|
3
|
+
* Creates and connects the McpServer instance for Claude Code integration.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { dirname, join } from "path";
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { registerTools } from "./mcp-tools.js";
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create and start MCP server with stdio transport.
|
|
18
|
+
* All logs MUST use console.error() — stdout is reserved for MCP protocol.
|
|
19
|
+
* @param {object} api - zca-js API instance
|
|
20
|
+
* @param {import("./message-buffer.js").MessageBuffer} buffer
|
|
21
|
+
* @param {import("./thread-filter.js").ThreadFilter} filter
|
|
22
|
+
* @param {object} config - MCP config
|
|
23
|
+
* @param {import("./thread-name-cache.js").ThreadNameCache} [nameCache] - Thread name cache
|
|
24
|
+
* @returns {Promise<McpServer>}
|
|
25
|
+
*/
|
|
26
|
+
export async function createMCPServer(api, buffer, filter, config, nameCache) {
|
|
27
|
+
const server = new McpServer({
|
|
28
|
+
name: "zalo-agent",
|
|
29
|
+
version: pkg.version,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
registerTools(server, api, buffer, filter, config, nameCache);
|
|
33
|
+
|
|
34
|
+
const transport = new StdioServerTransport();
|
|
35
|
+
await server.connect(transport);
|
|
36
|
+
|
|
37
|
+
console.error("[mcp-server] MCP server connected via stdio transport");
|
|
38
|
+
|
|
39
|
+
return server;
|
|
40
|
+
}
|