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 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.2.0",
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
+ }