zalo-agent-cli 1.1.0 → 1.3.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 +44 -0
- package/package.json +5 -2
- package/src/cli.test.js +1 -0
- package/src/commands/listen.js +20 -3
- package/src/commands/login.js +15 -4
- package/src/commands/mcp.js +186 -0
- package/src/index.js +5 -2
- package/src/mcp/mcp-config.js +76 -0
- package/src/mcp/mcp-http-transport.js +75 -0
- package/src/mcp/mcp-server.js +33 -0
- package/src/mcp/mcp-tools.js +171 -0
- package/src/mcp/message-buffer.js +119 -0
- package/src/mcp/message-buffer.test.js +282 -0
- package/src/mcp/notifier.js +94 -0
- package/src/mcp/notifier.test.js +239 -0
- package/src/mcp/thread-filter.js +79 -0
- package/src/mcp/thread-filter.test.js +206 -0
- package/src/utils/qr-display.js +27 -16
- package/src/utils/qr-display.test.js +98 -0
- package/src/utils/qr-http-server.js +19 -5
package/README.md
CHANGED
|
@@ -30,6 +30,28 @@ Xây dựng trên [zca-js](https://github.com/RFS-ADRENO/zca-js).
|
|
|
30
30
|
> 15+ nhóm lệnh · listen mode + webhook · 55+ ngân hàng VN · đa tài khoản + proxy
|
|
31
31
|
> Xem [skill/SKILL.md](skill/SKILL.md) · [Eval scenarios](skill/evals/)
|
|
32
32
|
|
|
33
|
+
> [!NOTE]
|
|
34
|
+
> **Zalo Official Account (OA)** — v1.1.0 hỗ trợ Zalo OA API v3.0 chính thức:
|
|
35
|
+
> ```bash
|
|
36
|
+
> zalo-agent oa init # Setup wizard (interactive)
|
|
37
|
+
> zalo-agent oa init --app-id <ID> --secret <KEY> --skip-webhook # Non-interactive (AI agent)
|
|
38
|
+
> zalo-agent oa whoami # Xem thông tin OA
|
|
39
|
+
> zalo-agent oa msg text <user-id> "Xin chào" # Gửi tin nhắn
|
|
40
|
+
> zalo-agent oa listen -p 3000 # Webhook listener
|
|
41
|
+
> ```
|
|
42
|
+
> OAuth login · gửi tin nhắn · quản lý follower · tag · webhook listener · VPS support
|
|
43
|
+
> Xem [docs/official-account.md](docs/official-account.md)
|
|
44
|
+
|
|
45
|
+
> [!TIP]
|
|
46
|
+
> **MCP Server (AI Agent Integration)** — v1.2.0 hỗ trợ Model Context Protocol cho Claude Code và các MCP client:
|
|
47
|
+
> ```bash
|
|
48
|
+
> zalo-agent mcp start # stdio (local Claude Code)
|
|
49
|
+
> zalo-agent mcp start --http 3847 --auth your-secret # HTTP (VPS)
|
|
50
|
+
> ```
|
|
51
|
+
> 4 tools: get_messages · send_message · list_threads · mark_read
|
|
52
|
+
> Auto-reconnect · thread filter · noise reduction · group notifications
|
|
53
|
+
> Xem [MCP Guide](skill/references/mcp-guide.md)
|
|
54
|
+
|
|
33
55
|
---
|
|
34
56
|
|
|
35
57
|
## Cài đặt
|
|
@@ -138,6 +160,28 @@ CLI tool for Zalo automation — multi-account, proxy support, bank transfers, Q
|
|
|
138
160
|
> 15+ command groups · listen mode + webhook · 55+ VN banks · multi-account + proxy
|
|
139
161
|
> See [skill/SKILL.md](skill/SKILL.md) · [Eval scenarios](skill/evals/)
|
|
140
162
|
|
|
163
|
+
> [!NOTE]
|
|
164
|
+
> **Zalo Official Account (OA)** — v1.1.0 adds official Zalo OA API v3.0:
|
|
165
|
+
> ```bash
|
|
166
|
+
> zalo-agent oa init # Setup wizard (interactive)
|
|
167
|
+
> zalo-agent oa init --app-id <ID> --secret <KEY> --skip-webhook # Non-interactive (AI agent)
|
|
168
|
+
> zalo-agent oa whoami # OA profile
|
|
169
|
+
> zalo-agent oa msg text <user-id> "Hello" # Send message
|
|
170
|
+
> zalo-agent oa listen -p 3000 # Webhook listener
|
|
171
|
+
> ```
|
|
172
|
+
> OAuth login · messaging · follower management · tags · webhook listener · VPS support
|
|
173
|
+
> See [docs/official-account.md](docs/official-account.md)
|
|
174
|
+
|
|
175
|
+
> [!TIP]
|
|
176
|
+
> **MCP Server (AI Agent Integration)** — v1.2.0 adds Model Context Protocol support for Claude Code and MCP clients:
|
|
177
|
+
> ```bash
|
|
178
|
+
> zalo-agent mcp start # stdio (local Claude Code)
|
|
179
|
+
> zalo-agent mcp start --http 3847 --auth your-secret # HTTP (VPS)
|
|
180
|
+
> ```
|
|
181
|
+
> 4 tools: get_messages · send_message · list_threads · mark_read
|
|
182
|
+
> Auto-reconnect · thread filter · noise reduction · group notifications
|
|
183
|
+
> See [MCP Guide](skill/references/mcp-guide.md)
|
|
184
|
+
|
|
141
185
|
### Quick Start
|
|
142
186
|
|
|
143
187
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zalo-agent-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments, Official Account API v3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,11 +42,14 @@
|
|
|
42
42
|
"node": ">=20"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
45
46
|
"chalk": "^5.3.0",
|
|
46
47
|
"commander": "^14.0.3",
|
|
48
|
+
"express": "^5.2.1",
|
|
47
49
|
"https-proxy-agent": "^8.0.0",
|
|
48
50
|
"node-fetch": "^3.3.0",
|
|
49
|
-
"zca-js": "^2.1.1"
|
|
51
|
+
"zca-js": "^2.1.1",
|
|
52
|
+
"zod": "^4.3.6"
|
|
50
53
|
},
|
|
51
54
|
"devDependencies": {
|
|
52
55
|
"eslint": "^10.0.3",
|
package/src/cli.test.js
CHANGED
package/src/commands/listen.js
CHANGED
|
@@ -108,7 +108,21 @@ export function registerListenCommand(program) {
|
|
|
108
108
|
if (opts.filter === "group" && msg.type !== THREAD_GROUP) return;
|
|
109
109
|
if (!opts.self && msg.isSelf) return;
|
|
110
110
|
|
|
111
|
-
const
|
|
111
|
+
const rawContent = msg.data.content;
|
|
112
|
+
const isText = typeof rawContent === "string";
|
|
113
|
+
const msgType = msg.data.msgType || null;
|
|
114
|
+
// Build readable display: show type + title/href for non-text
|
|
115
|
+
let displayContent;
|
|
116
|
+
if (isText) {
|
|
117
|
+
displayContent = rawContent;
|
|
118
|
+
} else if (rawContent && typeof rawContent === "object") {
|
|
119
|
+
const parts = [msgType || "attachment"];
|
|
120
|
+
if (rawContent.title) parts.push(`"${rawContent.title}"`);
|
|
121
|
+
if (rawContent.href) parts.push(rawContent.href);
|
|
122
|
+
displayContent = `[${parts.join(" | ")}]`;
|
|
123
|
+
} else {
|
|
124
|
+
displayContent = `[${msgType || "non-text"}]`;
|
|
125
|
+
}
|
|
112
126
|
const data = {
|
|
113
127
|
event: "message",
|
|
114
128
|
msgId: msg.data.msgId,
|
|
@@ -116,13 +130,16 @@ export function registerListenCommand(program) {
|
|
|
116
130
|
threadId: msg.threadId,
|
|
117
131
|
type: msg.type,
|
|
118
132
|
isSelf: msg.isSelf,
|
|
119
|
-
|
|
133
|
+
uidFrom: msg.data.uidFrom || null,
|
|
134
|
+
dName: msg.data.dName || null,
|
|
135
|
+
msgType,
|
|
136
|
+
content: rawContent,
|
|
120
137
|
};
|
|
121
138
|
const dir = msg.isSelf ? "→" : "←";
|
|
122
139
|
const typeLabel = msg.type === THREAD_USER ? "DM" : "GR";
|
|
123
140
|
emitEvent(
|
|
124
141
|
data,
|
|
125
|
-
`${dir} [${typeLabel}] [${msg.threadId}] ${
|
|
142
|
+
`${dir} [${typeLabel}] [${msg.threadId}] ${displayContent} (msgId: ${msg.data.msgId})`,
|
|
126
143
|
);
|
|
127
144
|
});
|
|
128
145
|
}
|
package/src/commands/login.js
CHANGED
|
@@ -26,6 +26,7 @@ export function registerLoginCommands(program) {
|
|
|
26
26
|
.option("-p, --proxy <url>", "Proxy URL (http/https/socks5://[user:pass@]host:port)")
|
|
27
27
|
.option("-n, --name <label>", "Friendly name for this account", "")
|
|
28
28
|
.option("--qr-url", "Start local HTTP server to view QR in browser (for VPS/headless)")
|
|
29
|
+
.option("-q, --qr-port <port>", "Port for QR HTTP server (default: 18927)", parseInt)
|
|
29
30
|
.option("--credentials <path>", "Login from exported credentials file (skip QR)")
|
|
30
31
|
.action(async (opts) => {
|
|
31
32
|
// Credential-based login (headless/CI)
|
|
@@ -55,8 +56,9 @@ export function registerLoginCommands(program) {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
// QR-based login
|
|
59
|
+
const jsonMode = program.opts().json;
|
|
58
60
|
if (opts.proxy) info(`Using proxy: ${maskProxy(opts.proxy)}`);
|
|
59
|
-
info("Generating QR code... Scan with Zalo mobile app.");
|
|
61
|
+
if (!jsonMode) info("Generating QR code... Scan with Zalo mobile app.");
|
|
60
62
|
|
|
61
63
|
let qrServer = null;
|
|
62
64
|
try {
|
|
@@ -65,7 +67,7 @@ export function registerLoginCommands(program) {
|
|
|
65
67
|
|
|
66
68
|
// Always start HTTP server for QR scanning (no flag needed)
|
|
67
69
|
if (!qrServer) {
|
|
68
|
-
qrServer = startQrServer(getQRPath());
|
|
70
|
+
qrServer = startQrServer(getQRPath(), opts.qrPort || 18927);
|
|
69
71
|
}
|
|
70
72
|
});
|
|
71
73
|
|
|
@@ -79,9 +81,18 @@ export function registerLoginCommands(program) {
|
|
|
79
81
|
const creds = extractCredentials();
|
|
80
82
|
saveCredentials(ownId, creds);
|
|
81
83
|
addAccount(ownId, displayName, opts.proxy);
|
|
82
|
-
|
|
84
|
+
|
|
85
|
+
if (jsonMode) {
|
|
86
|
+
console.log(JSON.stringify({ event: "login_success", ownId, name: displayName }));
|
|
87
|
+
} else {
|
|
88
|
+
success(`Logged in as ${displayName} (${ownId})`);
|
|
89
|
+
}
|
|
83
90
|
} catch (e) {
|
|
84
|
-
|
|
91
|
+
if (jsonMode) {
|
|
92
|
+
console.log(JSON.stringify({ event: "login_error", message: e.message }));
|
|
93
|
+
} else {
|
|
94
|
+
error(`Login failed: ${e.message}`);
|
|
95
|
+
}
|
|
85
96
|
process.exit(1);
|
|
86
97
|
} finally {
|
|
87
98
|
if (qrServer) qrServer.close();
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server command — starts a Model Context Protocol server over stdio transport.
|
|
3
|
+
* Allows Claude Code and other MCP clients to read/send Zalo messages via tool calls.
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: All diagnostic output uses console.error() — stdout is the MCP transport channel.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getApi, autoLogin, clearSession } from "../core/zalo-client.js";
|
|
9
|
+
import { MessageBuffer } from "../mcp/message-buffer.js";
|
|
10
|
+
import { ThreadFilter } from "../mcp/thread-filter.js";
|
|
11
|
+
import { loadMCPConfig, parseDuration } from "../mcp/mcp-config.js";
|
|
12
|
+
import { createMCPServer } from "../mcp/mcp-server.js";
|
|
13
|
+
import { registerTools } from "../mcp/mcp-tools.js";
|
|
14
|
+
import { createHTTPServer } from "../mcp/mcp-http-transport.js";
|
|
15
|
+
import { ZaloNotifier } from "../mcp/notifier.js";
|
|
16
|
+
|
|
17
|
+
/** Zalo close code for duplicate web session — fatal, do not retry */
|
|
18
|
+
const CLOSE_DUPLICATE = 3000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a raw zca-js message event into the buffer's message shape.
|
|
22
|
+
* @param {object} msg - Raw zca-js message event
|
|
23
|
+
* @returns {object} Normalized message
|
|
24
|
+
*/
|
|
25
|
+
function normalizeMessage(msg) {
|
|
26
|
+
const rawContent = msg.data.content;
|
|
27
|
+
const isText = typeof rawContent === "string";
|
|
28
|
+
return {
|
|
29
|
+
id: msg.data.msgId,
|
|
30
|
+
threadId: msg.threadId,
|
|
31
|
+
threadType: msg.type === 0 ? "dm" : "group",
|
|
32
|
+
senderId: msg.data.uidFrom || null,
|
|
33
|
+
senderName: msg.data.dName || null,
|
|
34
|
+
text: isText
|
|
35
|
+
? rawContent
|
|
36
|
+
: rawContent?.title || rawContent?.href || `[${msg.data.msgType || "attachment"}]`,
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
type: isText ? "text" : msg.data.msgType || "attachment",
|
|
39
|
+
attachment:
|
|
40
|
+
!isText && rawContent
|
|
41
|
+
? {
|
|
42
|
+
type: msg.data.msgType,
|
|
43
|
+
url: rawContent.href || null,
|
|
44
|
+
description: rawContent.title || null,
|
|
45
|
+
}
|
|
46
|
+
: null,
|
|
47
|
+
replyTo: null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerMCPCommands(program) {
|
|
52
|
+
const mcp = program.command("mcp").description("MCP server for AI agent integration");
|
|
53
|
+
|
|
54
|
+
mcp.command("start")
|
|
55
|
+
.description("Start MCP server (stdio or HTTP transport)")
|
|
56
|
+
.option("--config <path>", "Config file path (default: ~/.zalo-agent-cli/mcp-config.json)")
|
|
57
|
+
.option("--http <port>", "Use HTTP transport on specified port (default: stdio)")
|
|
58
|
+
.option("--auth <token>", "Bearer token for HTTP auth (only with --http)")
|
|
59
|
+
.action(async (opts) => {
|
|
60
|
+
// Perform login explicitly here — preAction hook skips "mcp"
|
|
61
|
+
try {
|
|
62
|
+
await autoLogin(false);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error("[mcp] Auto-login failed:", e.message);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Load MCP config (config path option reserved for future use)
|
|
69
|
+
const config = loadMCPConfig();
|
|
70
|
+
console.error("[mcp] Config loaded:", JSON.stringify(config.limits));
|
|
71
|
+
|
|
72
|
+
// Build buffer + filter from config
|
|
73
|
+
const maxAge = parseDuration(config.limits?.bufferMaxAge ?? "2h");
|
|
74
|
+
const maxSize = config.limits?.bufferMaxSize ?? 500;
|
|
75
|
+
const buffer = new MessageBuffer(maxSize, maxAge);
|
|
76
|
+
const filter = new ThreadFilter(config);
|
|
77
|
+
|
|
78
|
+
// Start MCP server — stdio (default) or HTTP
|
|
79
|
+
try {
|
|
80
|
+
if (opts.http) {
|
|
81
|
+
const port = Number(opts.http);
|
|
82
|
+
const deps = { api: getApi(), buffer, filter, config };
|
|
83
|
+
createHTTPServer(registerTools, deps, port, opts.auth || null);
|
|
84
|
+
console.error(`[mcp] HTTP server started on port ${port}`);
|
|
85
|
+
} else {
|
|
86
|
+
await createMCPServer(getApi(), buffer, filter, config);
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.error("[mcp] Failed to start MCP server:", e.message);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Setup notifier (sends to Zalo group when agent is offline)
|
|
94
|
+
const notifier = new ZaloNotifier(getApi(), config);
|
|
95
|
+
|
|
96
|
+
let reconnectCount = 0;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Attach Zalo listener handlers to the current API instance.
|
|
100
|
+
* Must be called again after each re-login with the new API instance.
|
|
101
|
+
* @param {object} api - zca-js API instance
|
|
102
|
+
*/
|
|
103
|
+
function attachListenerHandlers(api) {
|
|
104
|
+
api.listener.on("message", (msg) => {
|
|
105
|
+
// Skip self-sent messages
|
|
106
|
+
if (msg.isSelf) return;
|
|
107
|
+
|
|
108
|
+
const normalized = normalizeMessage(msg);
|
|
109
|
+
|
|
110
|
+
// Apply thread watch filter
|
|
111
|
+
if (!filter.shouldWatch(normalized.threadId, normalized.threadType)) return;
|
|
112
|
+
|
|
113
|
+
// Apply noise filter (stickers, system msgs, short emoji)
|
|
114
|
+
if (!filter.shouldKeep(normalized)) return;
|
|
115
|
+
|
|
116
|
+
buffer.push(normalized.threadId, normalized);
|
|
117
|
+
notifier.onMessage(normalized);
|
|
118
|
+
console.error(
|
|
119
|
+
`[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
api.listener.on("connected", () => {
|
|
124
|
+
if (reconnectCount > 0) {
|
|
125
|
+
console.error(`[mcp] Reconnected (#${reconnectCount})`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
api.listener.on("disconnected", (code) => {
|
|
130
|
+
console.error(`[mcp] Disconnected (code: ${code}). Auto-retrying...`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
api.listener.on("closed", async (code) => {
|
|
134
|
+
if (code === CLOSE_DUPLICATE) {
|
|
135
|
+
console.error("[mcp] Duplicate Zalo Web session detected. Exiting.");
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
reconnectCount++;
|
|
139
|
+
console.error(
|
|
140
|
+
`[mcp] Connection closed (code: ${code}). Re-login in 5s... (reconnect #${reconnectCount})`,
|
|
141
|
+
);
|
|
142
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
143
|
+
try {
|
|
144
|
+
clearSession();
|
|
145
|
+
await autoLogin(false);
|
|
146
|
+
console.error("[mcp] Re-login successful. Restarting listener...");
|
|
147
|
+
const newApi = getApi();
|
|
148
|
+
attachListenerHandlers(newApi);
|
|
149
|
+
newApi.listener.start({ retryOnClose: true });
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.error(`[mcp] Re-login failed: ${e.message}. Retrying in 30s...`);
|
|
152
|
+
await new Promise((r) => setTimeout(r, 30000));
|
|
153
|
+
try {
|
|
154
|
+
clearSession();
|
|
155
|
+
await autoLogin(false);
|
|
156
|
+
const retryApi = getApi();
|
|
157
|
+
attachListenerHandlers(retryApi);
|
|
158
|
+
retryApi.listener.start({ retryOnClose: true });
|
|
159
|
+
console.error("[mcp] Re-login successful on retry.");
|
|
160
|
+
} catch (e2) {
|
|
161
|
+
console.error(`[mcp] Re-login retry failed: ${e2.message}. Exiting.`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
api.listener.on("error", () => {
|
|
168
|
+
// WS errors are followed by close/disconnect — suppress to avoid noise
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Wire listener and start
|
|
173
|
+
try {
|
|
174
|
+
const api = getApi();
|
|
175
|
+
attachListenerHandlers(api);
|
|
176
|
+
api.listener.start({ retryOnClose: true });
|
|
177
|
+
console.error("[mcp] Zalo listener started. MCP server ready.");
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.error("[mcp] Failed to start listener:", e.message);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Keep process alive (MCP server runs on stdio — process must not exit)
|
|
184
|
+
await new Promise(() => {});
|
|
185
|
+
});
|
|
186
|
+
}
|
package/src/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import { registerLabelCommands } from "./commands/label.js";
|
|
|
27
27
|
import { registerCatalogCommands } from "./commands/catalog.js";
|
|
28
28
|
import { registerListenCommand } from "./commands/listen.js";
|
|
29
29
|
import { registerOACommands } from "./commands/oa.js";
|
|
30
|
+
import { registerMCPCommands } from "./commands/mcp.js";
|
|
30
31
|
import { autoLogin } from "./core/zalo-client.js";
|
|
31
32
|
import { checkForUpdates, selfUpdate } from "./utils/update-check.js";
|
|
32
33
|
import { success, error, warning } from "./utils/output.js";
|
|
@@ -44,7 +45,8 @@ program
|
|
|
44
45
|
.hook("preAction", async (thisCommand) => {
|
|
45
46
|
const cmdName = thisCommand.args?.[0] || thisCommand.name();
|
|
46
47
|
// Suppress zca-js internal logs in JSON mode to keep stdout clean for piping
|
|
47
|
-
if (program.opts().json) {
|
|
48
|
+
if (program.opts().json || cmdName === "mcp") {
|
|
49
|
+
// Suppress zca-js stdout logs: JSON mode needs clean output, MCP uses stdout as transport
|
|
48
50
|
process.env.ZALO_JSON_MODE = "1";
|
|
49
51
|
} else if (cmdName !== "oa") {
|
|
50
52
|
// OA commands use official Zalo API — no disclaimer needed
|
|
@@ -52,7 +54,7 @@ program
|
|
|
52
54
|
console.log();
|
|
53
55
|
}
|
|
54
56
|
// Auto-login before any command that needs it (skip for login/account/oa commands)
|
|
55
|
-
const skipAutoLogin = ["login", "account", "help", "version", "update", "oa"].includes(cmdName);
|
|
57
|
+
const skipAutoLogin = ["login", "account", "help", "version", "update", "oa", "mcp"].includes(cmdName);
|
|
56
58
|
if (!skipAutoLogin) {
|
|
57
59
|
await autoLogin(program.opts().json);
|
|
58
60
|
}
|
|
@@ -88,5 +90,6 @@ registerLabelCommands(program);
|
|
|
88
90
|
registerCatalogCommands(program);
|
|
89
91
|
registerListenCommand(program);
|
|
90
92
|
registerOACommands(program);
|
|
93
|
+
registerMCPCommands(program);
|
|
91
94
|
|
|
92
95
|
program.parse();
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Load MCP config from disk, merged with defaults.
|
|
34
|
+
* @returns {object} MCP config
|
|
35
|
+
*/
|
|
36
|
+
export function loadMCPConfig() {
|
|
37
|
+
const defaults = getDefaultConfig();
|
|
38
|
+
try {
|
|
39
|
+
const raw = readFileSync(MCP_CONFIG_FILE, "utf-8");
|
|
40
|
+
const saved = JSON.parse(raw);
|
|
41
|
+
// Shallow merge: saved values override defaults
|
|
42
|
+
return {
|
|
43
|
+
...defaults,
|
|
44
|
+
...saved,
|
|
45
|
+
notify: { ...defaults.notify, ...saved.notify },
|
|
46
|
+
limits: { ...defaults.limits, ...saved.limits },
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
// File doesn't exist or invalid JSON — use defaults
|
|
50
|
+
return defaults;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Save MCP config to disk.
|
|
56
|
+
* @param {object} config
|
|
57
|
+
*/
|
|
58
|
+
export function saveMCPConfig(config) {
|
|
59
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
60
|
+
writeFileSync(MCP_CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse duration string (e.g. "2h", "5m", "30s") to milliseconds.
|
|
65
|
+
* @param {string|number} duration
|
|
66
|
+
* @returns {number} Milliseconds
|
|
67
|
+
*/
|
|
68
|
+
export function parseDuration(duration) {
|
|
69
|
+
if (typeof duration === "number") return duration;
|
|
70
|
+
const match = String(duration).match(/^(\d+)\s*(h|m|s|ms)?$/i);
|
|
71
|
+
if (!match) return 0;
|
|
72
|
+
const value = parseInt(match[1], 10);
|
|
73
|
+
const unit = (match[2] || "ms").toLowerCase();
|
|
74
|
+
const multipliers = { h: 3600000, m: 60000, s: 1000, ms: 1 };
|
|
75
|
+
return value * (multipliers[unit] || 1);
|
|
76
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create HTTP MCP server with optional bearer token auth.
|
|
12
|
+
* @param {Function} registerToolsFn - Registers tools on a McpServer instance
|
|
13
|
+
* @param {object} deps - { api, buffer, filter, config }
|
|
14
|
+
* @param {number} port
|
|
15
|
+
* @param {string|null} authToken - Bearer token (null = no auth)
|
|
16
|
+
* @returns {import("http").Server}
|
|
17
|
+
*/
|
|
18
|
+
export function createHTTPServer(registerToolsFn, deps, port, authToken) {
|
|
19
|
+
const app = express();
|
|
20
|
+
app.use(express.json());
|
|
21
|
+
|
|
22
|
+
// Auth middleware — skips /health
|
|
23
|
+
if (authToken) {
|
|
24
|
+
app.use((req, res, next) => {
|
|
25
|
+
if (req.path === "/health") return next();
|
|
26
|
+
const token = req.headers.authorization?.slice(7); // strip "Bearer "
|
|
27
|
+
if (token !== authToken) {
|
|
28
|
+
return res.status(401).json({ error: "Unauthorized" });
|
|
29
|
+
}
|
|
30
|
+
next();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// MCP endpoint — stateless, fresh server+transport per request
|
|
35
|
+
app.post("/mcp", async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const server = new McpServer({ name: "zalo-agent", version: "1.0.0" });
|
|
38
|
+
registerToolsFn(server, deps.api, deps.buffer, deps.filter, deps.config);
|
|
39
|
+
|
|
40
|
+
const transport = new StreamableHTTPServerTransport({
|
|
41
|
+
sessionIdGenerator: undefined, // stateless mode
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
res.on("close", () => {
|
|
45
|
+
transport.close();
|
|
46
|
+
server.close();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await server.connect(transport);
|
|
50
|
+
await transport.handleRequest(req, res, req.body);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error("MCP HTTP error:", err);
|
|
53
|
+
if (!res.headersSent) {
|
|
54
|
+
res.status(500).json({
|
|
55
|
+
jsonrpc: "2.0",
|
|
56
|
+
error: { code: -32603, message: "Internal server error" },
|
|
57
|
+
id: null,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Health check — no auth required
|
|
64
|
+
app.get("/health", (req, res) => {
|
|
65
|
+
res.json({
|
|
66
|
+
status: "ok",
|
|
67
|
+
uptime: Math.floor(process.uptime()),
|
|
68
|
+
threads: deps.buffer.getStats().length,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return app.listen(port, "0.0.0.0", () => {
|
|
73
|
+
console.error(`MCP HTTP server listening on port ${port}`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server setup with stdio transport.
|
|
3
|
+
* Creates and connects the McpServer instance for Claude Code integration.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { registerTools } from "./mcp-tools.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create and start MCP server with stdio transport.
|
|
12
|
+
* All logs MUST use console.error() — stdout is reserved for MCP protocol.
|
|
13
|
+
* @param {object} api - zca-js API instance
|
|
14
|
+
* @param {import("./message-buffer.js").MessageBuffer} buffer
|
|
15
|
+
* @param {import("./thread-filter.js").ThreadFilter} filter
|
|
16
|
+
* @param {object} config - MCP config
|
|
17
|
+
* @returns {Promise<McpServer>}
|
|
18
|
+
*/
|
|
19
|
+
export async function createMCPServer(api, buffer, filter, config) {
|
|
20
|
+
const server = new McpServer({
|
|
21
|
+
name: "zalo-agent",
|
|
22
|
+
version: "1.0.0",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
registerTools(server, api, buffer, filter, config);
|
|
26
|
+
|
|
27
|
+
const transport = new StdioServerTransport();
|
|
28
|
+
await server.connect(transport);
|
|
29
|
+
|
|
30
|
+
console.error("[mcp-server] MCP server connected via stdio transport");
|
|
31
|
+
|
|
32
|
+
return server;
|
|
33
|
+
}
|