zalo-agent-cli 1.2.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 +20 -0
- package/package.json +5 -2
- 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/README.md
CHANGED
|
@@ -42,6 +42,16 @@ Xây dựng trên [zca-js](https://github.com/RFS-ADRENO/zca-js).
|
|
|
42
42
|
> OAuth login · gửi tin nhắn · quản lý follower · tag · webhook listener · VPS support
|
|
43
43
|
> Xem [docs/official-account.md](docs/official-account.md)
|
|
44
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
|
+
|
|
45
55
|
---
|
|
46
56
|
|
|
47
57
|
## Cài đặt
|
|
@@ -162,6 +172,16 @@ CLI tool for Zalo automation — multi-account, proxy support, bank transfers, Q
|
|
|
162
172
|
> OAuth login · messaging · follower management · tags · webhook listener · VPS support
|
|
163
173
|
> See [docs/official-account.md](docs/official-account.md)
|
|
164
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
|
+
|
|
165
185
|
### Quick Start
|
|
166
186
|
|
|
167
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",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool registrations for Zalo message access and sending.
|
|
3
|
+
* Registers 4 tools: zalo_get_messages, zalo_send_message, zalo_list_threads, zalo_mark_read.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/** Thread type constants matching zca-js ThreadType enum */
|
|
9
|
+
const THREAD_USER = 0;
|
|
10
|
+
const THREAD_GROUP = 1;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wrap a result object into MCP tool content format.
|
|
14
|
+
* @param {object} result
|
|
15
|
+
* @returns {{ content: Array }}
|
|
16
|
+
*/
|
|
17
|
+
function ok(result) {
|
|
18
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wrap an error message into MCP tool error content format.
|
|
23
|
+
* @param {string} message
|
|
24
|
+
* @returns {{ content: Array, isError: true }}
|
|
25
|
+
*/
|
|
26
|
+
function err(message) {
|
|
27
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register all Zalo MCP tools on the server.
|
|
32
|
+
* @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
|
|
33
|
+
* @param {object} api - zca-js API instance
|
|
34
|
+
* @param {import("./message-buffer.js").MessageBuffer} buffer
|
|
35
|
+
* @param {import("./thread-filter.js").ThreadFilter} filter
|
|
36
|
+
* @param {object} config - MCP config
|
|
37
|
+
*/
|
|
38
|
+
export function registerTools(server, api, buffer, filter, config) {
|
|
39
|
+
const maxPerPoll = config.limits?.maxMessagesPerPoll ?? 20;
|
|
40
|
+
|
|
41
|
+
// --- zalo_get_messages ---
|
|
42
|
+
server.registerTool(
|
|
43
|
+
"zalo_get_messages",
|
|
44
|
+
{
|
|
45
|
+
title: "Get Zalo Messages",
|
|
46
|
+
description:
|
|
47
|
+
"Get messages from Zalo threads (DMs and groups). Returns buffered messages since last read. Use 'since' cursor from previous response for incremental polling.",
|
|
48
|
+
inputSchema: z.object({
|
|
49
|
+
threadId: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("Thread ID to read from. Omit for all watched threads."),
|
|
53
|
+
since: z
|
|
54
|
+
.number()
|
|
55
|
+
.int()
|
|
56
|
+
.min(0)
|
|
57
|
+
.default(0)
|
|
58
|
+
.describe("Cursor from previous read for incremental polling"),
|
|
59
|
+
limit: z
|
|
60
|
+
.number()
|
|
61
|
+
.int()
|
|
62
|
+
.min(1)
|
|
63
|
+
.max(100)
|
|
64
|
+
.default(maxPerPoll)
|
|
65
|
+
.describe("Max messages to return"),
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
async ({ threadId, since, limit }) => {
|
|
69
|
+
try {
|
|
70
|
+
const result = buffer.read(threadId, since, limit);
|
|
71
|
+
return ok(result);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
console.error("[mcp-tools] zalo_get_messages error:", e.message);
|
|
74
|
+
return err(e.message);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// --- zalo_send_message ---
|
|
80
|
+
server.registerTool(
|
|
81
|
+
"zalo_send_message",
|
|
82
|
+
{
|
|
83
|
+
title: "Send Zalo Message",
|
|
84
|
+
description:
|
|
85
|
+
"Send a text message to a Zalo thread (DM or group). threadType: 0=DM(User), 1=Group.",
|
|
86
|
+
inputSchema: z.object({
|
|
87
|
+
threadId: z.string().describe("Thread ID to send message to"),
|
|
88
|
+
text: z.string().min(1).describe("Message text to send"),
|
|
89
|
+
threadType: z
|
|
90
|
+
.number()
|
|
91
|
+
.int()
|
|
92
|
+
.min(0)
|
|
93
|
+
.max(1)
|
|
94
|
+
.default(THREAD_USER)
|
|
95
|
+
.describe("Thread type: 0=DM(User), 1=Group"),
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
async ({ threadId, text, threadType }) => {
|
|
99
|
+
try {
|
|
100
|
+
const result = await api.sendMessage(text, threadId, Number(threadType));
|
|
101
|
+
const messageId = result?.message?.msgId ?? result?.msgId ?? null;
|
|
102
|
+
return ok({ success: true, messageId });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error("[mcp-tools] zalo_send_message error:", e.message);
|
|
105
|
+
return err(e.message);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// --- zalo_list_threads ---
|
|
111
|
+
server.registerTool(
|
|
112
|
+
"zalo_list_threads",
|
|
113
|
+
{
|
|
114
|
+
title: "List Zalo Threads",
|
|
115
|
+
description:
|
|
116
|
+
"List all Zalo threads currently buffered with unread message counts. Useful for discovering active conversations.",
|
|
117
|
+
inputSchema: z.object({
|
|
118
|
+
type: z
|
|
119
|
+
.enum(["group", "dm", "all"])
|
|
120
|
+
.default("all")
|
|
121
|
+
.describe("Filter by thread type: 'dm', 'group', or 'all'"),
|
|
122
|
+
}),
|
|
123
|
+
},
|
|
124
|
+
async ({ type }) => {
|
|
125
|
+
try {
|
|
126
|
+
const stats = buffer.getStats(0);
|
|
127
|
+
// Enrich each stat entry with threadType by peeking at the first buffered message.
|
|
128
|
+
// buffer._threads is a Map<threadId, { messages: Array, lastActivity: number }>
|
|
129
|
+
const enriched = stats.map((t) => {
|
|
130
|
+
const thread = buffer._threads.get(t.threadId);
|
|
131
|
+
const threadType = thread?.messages?.[0]?.threadType ?? "unknown";
|
|
132
|
+
return { ...t, threadType };
|
|
133
|
+
});
|
|
134
|
+
const filtered =
|
|
135
|
+
type === "all"
|
|
136
|
+
? enriched
|
|
137
|
+
: enriched.filter((t) => t.threadType === type);
|
|
138
|
+
return ok({ threads: filtered, total: filtered.length });
|
|
139
|
+
} catch (e) {
|
|
140
|
+
console.error("[mcp-tools] zalo_list_threads error:", e.message);
|
|
141
|
+
return err(e.message);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// --- zalo_mark_read ---
|
|
147
|
+
server.registerTool(
|
|
148
|
+
"zalo_mark_read",
|
|
149
|
+
{
|
|
150
|
+
title: "Mark Zalo Messages Read",
|
|
151
|
+
description:
|
|
152
|
+
"Discard buffered messages up to and including the given cursor. Use the cursor returned by zalo_get_messages.",
|
|
153
|
+
inputSchema: z.object({
|
|
154
|
+
cursor: z
|
|
155
|
+
.number()
|
|
156
|
+
.int()
|
|
157
|
+
.min(0)
|
|
158
|
+
.describe("Cursor value returned from a previous zalo_get_messages call"),
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
161
|
+
async ({ cursor }) => {
|
|
162
|
+
try {
|
|
163
|
+
const discarded = buffer.markRead(cursor);
|
|
164
|
+
return ok({ success: true, discarded });
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error("[mcp-tools] zalo_mark_read error:", e.message);
|
|
167
|
+
return err(e.message);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
}
|