weixin-mcp 1.0.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 ADDED
@@ -0,0 +1,168 @@
1
+ # weixin-mcp
2
+
3
+ `weixin-mcp` 是一个基于 MCP 的微信服务端封装,复用 OpenClaw weixin 插件保存的账号 token,把微信能力暴露给 Claude Desktop 或其他 MCP 客户端。
4
+
5
+ ## 功能
6
+
7
+ - 发送微信文本消息
8
+ - 获取联系人列表
9
+ - 拉取新消息
10
+ - 查询聊天历史
11
+ - 支持复用 OpenClaw weixin 插件已有登录态
12
+ - 支持独立扫码登录
13
+
14
+ ## 安装
15
+
16
+ ### 方式 1:直接用 `npx`
17
+
18
+ ```bash
19
+ npx weixin-mcp
20
+ ```
21
+
22
+ 如果你只想执行扫码登录:
23
+
24
+ ```bash
25
+ npx weixin-mcp weixin-login
26
+ ```
27
+
28
+ 更稳妥的做法是先安装到本地或全局,再运行下面的脚本。
29
+
30
+ ### 方式 2:`npm install`
31
+
32
+ ```bash
33
+ npm install
34
+ npm run build
35
+ ```
36
+
37
+ 构建后可用以下命令:
38
+
39
+ ```bash
40
+ npm start
41
+ npm run login
42
+ ```
43
+
44
+ ## 使用方式
45
+
46
+ ### 场景 A:已经安装 OpenClaw weixin 插件
47
+
48
+ 如果你已经通过 OpenClaw weixin 插件登录过微信,并且本地已有 token 文件:
49
+
50
+ ```bash
51
+ npm start
52
+ ```
53
+
54
+ 默认会从下面目录读取账号信息:
55
+
56
+ ```bash
57
+ ~/.openclaw/openclaw-weixin/accounts/
58
+ ```
59
+
60
+ 如果目录下有多个账号,可通过环境变量 `WEIXIN_ACCOUNT_ID` 指定。
61
+
62
+ ### 场景 B:独立使用
63
+
64
+ 如果你没有 OpenClaw weixin 插件,或者想单独给 `weixin-mcp` 登录:
65
+
66
+ ```bash
67
+ npm run login
68
+ ```
69
+
70
+ 终端会显示二维码,扫码确认后会把 token 保存到本地。完成后启动 MCP server:
71
+
72
+ ```bash
73
+ npm start
74
+ ```
75
+
76
+ ## Claude Desktop 集成
77
+
78
+ 在 Claude Desktop 的 `claude_desktop_config.json` 中加入:
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "weixin": {
84
+ "command": "node",
85
+ "args": ["/absolute/path/to/weixin-mcp/dist/index.js"],
86
+ "env": {
87
+ "OPENCLAW_STATE_DIR": "/Users/yourname/.openclaw",
88
+ "WEIXIN_ACCOUNT_ID": "your-account-id"
89
+ }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ 如果你是通过 npm 包安装,也可以直接用:
96
+
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "weixin": {
101
+ "command": "npx",
102
+ "args": ["weixin-mcp"],
103
+ "env": {
104
+ "OPENCLAW_STATE_DIR": "/Users/yourname/.openclaw"
105
+ }
106
+ }
107
+ }
108
+ }
109
+ ```
110
+
111
+ 修改配置后重启 Claude Desktop。
112
+
113
+ ## 工具说明
114
+
115
+ | 工具名 | 说明 | 主要参数 |
116
+ | --- | --- | --- |
117
+ | `weixin_send` | 发送文本消息 | `to`, `text` |
118
+ | `weixin_get_contacts` | 获取联系人列表 | 无 |
119
+ | `weixin_poll_messages` | 拉取新消息 | `since_ts` 可选,毫秒时间戳 |
120
+ | `weixin_get_history` | 获取与某个联系人的聊天历史 | `to`, `limit` |
121
+
122
+ ## 环境变量
123
+
124
+ | 变量名 | 默认值 | 说明 |
125
+ | --- | --- | --- |
126
+ | `OPENCLAW_STATE_DIR` | `~/.openclaw` | OpenClaw 状态目录,账号文件路径会解析为 `${OPENCLAW_STATE_DIR}/openclaw-weixin/accounts/` |
127
+ | `WEIXIN_ACCOUNT_ID` | 账号目录中的第一个账号 | 指定要使用的账号 ID,对应 `accounts/<accountId>.json` |
128
+
129
+ 兼容性说明:
130
+
131
+ - `src/index.ts` 仍兼容读取旧变量 `CLAWDBOT_STATE_DIR`
132
+ - 若 token 失效,工具会返回清晰错误并提示重新执行 `npm run login`
133
+
134
+ ## 常见问题
135
+
136
+ ### 1. 提示找不到账号
137
+
138
+ 先确认以下目录下是否存在 `*.json`:
139
+
140
+ ```bash
141
+ ~/.openclaw/openclaw-weixin/accounts/
142
+ ```
143
+
144
+ 如果没有,执行:
145
+
146
+ ```bash
147
+ npm run login
148
+ ```
149
+
150
+ ### 2. 提示 token 过期或鉴权失败
151
+
152
+ 重新扫码登录:
153
+
154
+ ```bash
155
+ npm run login
156
+ ```
157
+
158
+ ### 3. 网络偶发失败
159
+
160
+ 服务端会对网络错误自动重试 1 次。如果仍失败,请检查网络连接或微信接口可达性。
161
+
162
+ ## 开发
163
+
164
+ ```bash
165
+ npm install
166
+ npm run build
167
+ npm test
168
+ ```
package/dist/api.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export declare const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
2
+ export declare class WeixinAuthError extends Error {
3
+ constructor(message?: string);
4
+ }
5
+ export declare class WeixinNetworkError extends Error {
6
+ constructor(message: string);
7
+ }
8
+ export declare function generateClientId(): string;
9
+ export declare function weixinRequest(requestPath: string, body: unknown, token: string, baseUrl?: string, retries?: number): Promise<unknown>;
10
+ export declare function sendTextMessage(to: string, text: string, token: string, baseUrl: string): Promise<unknown>;
11
+ export declare function getContacts(token: string, baseUrl: string): Promise<unknown>;
12
+ export declare function pollMessages(token: string, baseUrl: string, sinceTs?: number): Promise<unknown>;
13
+ export declare function getChatHistory(to: string, limit: number, token: string, baseUrl: string): Promise<unknown>;
package/dist/api.js ADDED
@@ -0,0 +1,90 @@
1
+ import crypto from "node:crypto";
2
+ export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
3
+ const AUTH_ERROR_STATUSES = new Set([401, 403]);
4
+ export class WeixinAuthError extends Error {
5
+ constructor(message = "Authentication failed. Run npm run login to re-authenticate.") {
6
+ super(message);
7
+ this.name = "WeixinAuthError";
8
+ }
9
+ }
10
+ export class WeixinNetworkError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "WeixinNetworkError";
14
+ }
15
+ }
16
+ export function generateClientId() {
17
+ return `openclaw-weixin-mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
18
+ }
19
+ async function parseErrorResponse(res) {
20
+ try {
21
+ const text = await res.text();
22
+ return text.trim() || `HTTP ${res.status}`;
23
+ }
24
+ catch {
25
+ return `HTTP ${res.status}`;
26
+ }
27
+ }
28
+ function isTransientNetworkError(error) {
29
+ return error instanceof TypeError || error instanceof WeixinNetworkError;
30
+ }
31
+ export async function weixinRequest(requestPath, body, token, baseUrl = DEFAULT_BASE_URL, retries = 1) {
32
+ const url = `${baseUrl}${requestPath}`;
33
+ for (let attempt = 0; attempt <= retries; attempt++) {
34
+ try {
35
+ const res = await fetch(url, {
36
+ method: "POST",
37
+ headers: {
38
+ "Content-Type": "application/json",
39
+ Authorization: `Bearer ${token}`,
40
+ },
41
+ body: JSON.stringify(body),
42
+ });
43
+ if (AUTH_ERROR_STATUSES.has(res.status)) {
44
+ throw new WeixinAuthError();
45
+ }
46
+ if (!res.ok) {
47
+ const message = await parseErrorResponse(res);
48
+ throw new Error(`Weixin API error ${res.status}: ${message}`);
49
+ }
50
+ return res.json();
51
+ }
52
+ catch (error) {
53
+ if (error instanceof WeixinAuthError) {
54
+ throw error;
55
+ }
56
+ if (isTransientNetworkError(error) && attempt < retries) {
57
+ continue;
58
+ }
59
+ if (isTransientNetworkError(error)) {
60
+ throw new WeixinNetworkError(error instanceof Error ? error.message : "Network request failed");
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+ throw new WeixinNetworkError("Network request failed");
66
+ }
67
+ export async function sendTextMessage(to, text, token, baseUrl) {
68
+ return weixinRequest("/v1/message/send", {
69
+ msg: {
70
+ from_user_id: "",
71
+ to_user_id: to,
72
+ client_id: generateClientId(),
73
+ message_type: 2,
74
+ message_state: 2,
75
+ item_list: [{ type: 1, text_item: { text } }],
76
+ },
77
+ }, token, baseUrl);
78
+ }
79
+ export async function getContacts(token, baseUrl) {
80
+ return weixinRequest("/v1/contacts/list", { page_size: 50 }, token, baseUrl);
81
+ }
82
+ export async function pollMessages(token, baseUrl, sinceTs) {
83
+ return weixinRequest("/v1/updates/get", {
84
+ timeout_ms: 5000,
85
+ ...(sinceTs ? { since_ts: sinceTs } : {}),
86
+ }, token, baseUrl);
87
+ }
88
+ export async function getChatHistory(to, limit, token, baseUrl) {
89
+ return weixinRequest("/v1/message/history", { to_user_id: to, limit }, token, baseUrl);
90
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WeChat MCP Server
4
+ * Exposes WeChat messaging as MCP tools, reusing token from OpenClaw weixin plugin.
5
+ */
6
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WeChat MCP Server
4
+ * Exposes WeChat messaging as MCP tools, reusing token from OpenClaw weixin plugin.
5
+ */
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import os from "node:os";
12
+ import { DEFAULT_BASE_URL, getChatHistory, getContacts, pollMessages, sendTextMessage, WeixinAuthError, WeixinNetworkError, } from "./api.js";
13
+ // ── Auth / config ──────────────────────────────────────────────────────────
14
+ const STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() ||
15
+ process.env.CLAWDBOT_STATE_DIR?.trim() ||
16
+ path.join(os.homedir(), ".openclaw");
17
+ const WEIXIN_DIR = path.join(STATE_DIR, "openclaw-weixin", "accounts");
18
+ function loadAccount() {
19
+ const files = fs.readdirSync(WEIXIN_DIR).filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json"));
20
+ if (files.length === 0)
21
+ throw new Error("No WeChat account found. Run: npx @tencent-weixin/openclaw-weixin-cli install");
22
+ // Pick the first (or explicitly set) account
23
+ const accountId = process.env.WEIXIN_ACCOUNT_ID ?? files[0].replace(".json", "");
24
+ const filePath = path.join(WEIXIN_DIR, `${accountId}.json`);
25
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
26
+ if (!data.token)
27
+ throw new Error(`No token for account ${accountId}. Re-run QR login.`);
28
+ return { ...data, accountId };
29
+ }
30
+ // ── Weixin API ─────────────────────────────────────────────────────────────
31
+ function assertNonEmptyString(value, field) {
32
+ if (typeof value !== "string" || value.trim() === "") {
33
+ throw new Error(`Invalid argument "${field}": must be a non-empty string`);
34
+ }
35
+ return value.trim();
36
+ }
37
+ function formatToolError(error) {
38
+ if (error instanceof WeixinAuthError) {
39
+ return error.message;
40
+ }
41
+ if (error instanceof WeixinNetworkError) {
42
+ return `Network error while calling Weixin API: ${error.message}`;
43
+ }
44
+ if (error instanceof Error) {
45
+ return error.message;
46
+ }
47
+ return String(error);
48
+ }
49
+ // ── MCP Server ─────────────────────────────────────────────────────────────
50
+ const server = new Server({ name: "weixin-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
51
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
52
+ tools: [
53
+ {
54
+ name: "weixin_send",
55
+ description: "Send a WeChat message to a user (by user ID or OpenId)",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ to: { type: "string", description: "Recipient user ID / OpenId" },
60
+ text: { type: "string", description: "Message text to send" },
61
+ },
62
+ required: ["to", "text"],
63
+ },
64
+ },
65
+ {
66
+ name: "weixin_get_contacts",
67
+ description: "List WeChat contacts",
68
+ inputSchema: { type: "object", properties: {} },
69
+ },
70
+ {
71
+ name: "weixin_poll_messages",
72
+ description: "Poll for new WeChat messages since a timestamp",
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ since_ts: { type: "number", description: "Unix timestamp in ms (optional)" },
77
+ },
78
+ },
79
+ },
80
+ {
81
+ name: "weixin_get_history",
82
+ description: "Get chat history with a WeChat contact",
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ to: { type: "string", description: "Contact user ID / OpenId" },
87
+ limit: { type: "number", description: "Number of messages (default 20)" },
88
+ },
89
+ required: ["to"],
90
+ },
91
+ },
92
+ ],
93
+ }));
94
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
95
+ const account = loadAccount();
96
+ const { token, baseUrl = DEFAULT_BASE_URL } = account;
97
+ const { name, arguments: args } = req.params;
98
+ try {
99
+ let result;
100
+ if (name === "weixin_send") {
101
+ const { to, text } = (args ?? {});
102
+ const validatedTo = assertNonEmptyString(to, "to");
103
+ const validatedText = assertNonEmptyString(text, "text");
104
+ result = await sendTextMessage(validatedTo, validatedText, token, baseUrl);
105
+ }
106
+ else if (name === "weixin_get_contacts") {
107
+ result = await getContacts(token, baseUrl);
108
+ }
109
+ else if (name === "weixin_poll_messages") {
110
+ const { since_ts } = (args ?? {});
111
+ result = await pollMessages(token, baseUrl, since_ts);
112
+ }
113
+ else if (name === "weixin_get_history") {
114
+ const { to, limit = 20 } = (args ?? {});
115
+ const validatedTo = assertNonEmptyString(to, "to");
116
+ result = await getChatHistory(validatedTo, limit, token, baseUrl);
117
+ }
118
+ else {
119
+ throw new Error(`Unknown tool: ${name}`);
120
+ }
121
+ return {
122
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
123
+ };
124
+ }
125
+ catch (err) {
126
+ return {
127
+ content: [{ type: "text", text: `Error: ${formatToolError(err)}` }],
128
+ isError: true,
129
+ };
130
+ }
131
+ });
132
+ // ── Start ──────────────────────────────────────────────────────────────────
133
+ const transport = new StdioServerTransport();
134
+ await server.connect(transport);
135
+ console.error("WeChat MCP server running on stdio");
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * weixin-login — standalone QR login for weixin-mcp
4
+ * Usage: node dist/login.js
5
+ *
6
+ * Fetches a QR code from Weixin API, renders it in terminal,
7
+ * polls for scan confirmation, then saves token to:
8
+ * ~/.openclaw/openclaw-weixin/accounts/<accountId>.json
9
+ */
10
+ export {};
package/dist/login.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * weixin-login — standalone QR login for weixin-mcp
4
+ * Usage: node dist/login.js
5
+ *
6
+ * Fetches a QR code from Weixin API, renders it in terminal,
7
+ * polls for scan confirmation, then saves token to:
8
+ * ~/.openclaw/openclaw-weixin/accounts/<accountId>.json
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import crypto from "node:crypto";
14
+ // @ts-ignore — no types for qrcode-terminal
15
+ import qrcode from "qrcode-terminal";
16
+ const BASE_URL = "https://ilinkai.weixin.qq.com";
17
+ const BOT_TYPE = "3";
18
+ const STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() ||
19
+ path.join(os.homedir(), ".openclaw");
20
+ const ACCOUNTS_DIR = path.join(STATE_DIR, "openclaw-weixin", "accounts");
21
+ async function fetchQRCode() {
22
+ const url = `${BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=${BOT_TYPE}`;
23
+ const res = await fetch(url);
24
+ if (!res.ok)
25
+ throw new Error(`QR fetch failed: ${res.status}`);
26
+ return res.json();
27
+ }
28
+ async function pollStatus(qrcodeVal) {
29
+ const url = `${BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcodeVal)}`;
30
+ const res = await fetch(url, {
31
+ headers: { "iLink-App-ClientVersion": "1" },
32
+ });
33
+ if (!res.ok)
34
+ throw new Error(`Status poll failed: ${res.status}`);
35
+ return res.json();
36
+ }
37
+ function saveAccount(accountId, token, baseUrl, userId) {
38
+ fs.mkdirSync(ACCOUNTS_DIR, { recursive: true });
39
+ const filePath = path.join(ACCOUNTS_DIR, `${accountId}.json`);
40
+ const existing = fs.existsSync(filePath)
41
+ ? JSON.parse(fs.readFileSync(filePath, "utf-8"))
42
+ : {};
43
+ fs.writeFileSync(filePath, JSON.stringify({
44
+ ...existing,
45
+ token,
46
+ baseUrl,
47
+ ...(userId ? { userId } : {}),
48
+ savedAt: new Date().toISOString(),
49
+ }, null, 2));
50
+ console.log(`\n✅ Token saved to: ${filePath}`);
51
+ }
52
+ async function main() {
53
+ console.log("🔐 WeChat MCP — QR Login\n");
54
+ console.log("Fetching QR code...");
55
+ const { qrcode: qrcodeVal } = await fetchQRCode();
56
+ // Render QR in terminal
57
+ console.log("\nScan this QR code with WeChat:\n");
58
+ qrcode.generate(qrcodeVal, { small: true });
59
+ console.log("\nWaiting for scan...");
60
+ // Poll until confirmed or expired
61
+ let attempts = 0;
62
+ while (attempts < 60) {
63
+ await new Promise((r) => setTimeout(r, 2000));
64
+ attempts++;
65
+ const status = await pollStatus(qrcodeVal);
66
+ if (status.status === "scaned") {
67
+ process.stdout.write("\r✓ Scanned! Waiting for confirmation...");
68
+ }
69
+ else if (status.status === "confirmed") {
70
+ const token = status.bot_token;
71
+ if (!token)
72
+ throw new Error("No token in confirmed response");
73
+ const baseUrl = status.baseurl ?? BASE_URL;
74
+ const userId = status.ilink_user_id ?? status.ilink_bot_id;
75
+ // Use bot_id or a random ID as account identifier
76
+ const accountId = status.ilink_bot_id?.replace("@", "-").replace(".", "-")
77
+ ?? crypto.randomBytes(6).toString("hex") + "-im-bot";
78
+ saveAccount(accountId, token, baseUrl, userId ? `${userId}@im.wechat` : undefined);
79
+ console.log(`\n🎉 Logged in! Account: ${accountId}`);
80
+ console.log(` UserId: ${userId ?? "(unknown)"}`);
81
+ console.log("\nYou can now start the MCP server:");
82
+ console.log(" node dist/index.js\n");
83
+ process.exit(0);
84
+ }
85
+ else if (status.status === "expired") {
86
+ console.error("\n❌ QR code expired. Please run again.");
87
+ process.exit(1);
88
+ }
89
+ }
90
+ console.error("\n❌ Timeout waiting for scan.");
91
+ process.exit(1);
92
+ }
93
+ main().catch((err) => {
94
+ console.error("Error:", err);
95
+ process.exit(1);
96
+ });
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "weixin-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for WeChat (Weixin) — send messages via OpenClaw weixin plugin auth",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "start": "node dist/index.js",
10
+ "login": "node dist/login.js",
11
+ "dev": "npx ts-node --esm src/index.ts",
12
+ "test": "npm run build && node --test test/*.test.mjs"
13
+ },
14
+ "bin": {
15
+ "weixin-mcp": "./dist/index.js",
16
+ "weixin-login": "./dist/login.js"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.0.0",
20
+ "qrcode-terminal": "^0.12.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.0.0",
24
+ "typescript": "^5.0.0"
25
+ }
26
+ }
package/src/api.ts ADDED
@@ -0,0 +1,131 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
4
+
5
+ const AUTH_ERROR_STATUSES = new Set([401, 403]);
6
+
7
+ export class WeixinAuthError extends Error {
8
+ constructor(message = "Authentication failed. Run npm run login to re-authenticate.") {
9
+ super(message);
10
+ this.name = "WeixinAuthError";
11
+ }
12
+ }
13
+
14
+ export class WeixinNetworkError extends Error {
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = "WeixinNetworkError";
18
+ }
19
+ }
20
+
21
+ export function generateClientId(): string {
22
+ return `openclaw-weixin-mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
23
+ }
24
+
25
+ async function parseErrorResponse(res: Response): Promise<string> {
26
+ try {
27
+ const text = await res.text();
28
+ return text.trim() || `HTTP ${res.status}`;
29
+ } catch {
30
+ return `HTTP ${res.status}`;
31
+ }
32
+ }
33
+
34
+ function isTransientNetworkError(error: unknown): boolean {
35
+ return error instanceof TypeError || error instanceof WeixinNetworkError;
36
+ }
37
+
38
+ export async function weixinRequest(
39
+ requestPath: string,
40
+ body: unknown,
41
+ token: string,
42
+ baseUrl = DEFAULT_BASE_URL,
43
+ retries = 1,
44
+ ): Promise<unknown> {
45
+ const url = `${baseUrl}${requestPath}`;
46
+
47
+ for (let attempt = 0; attempt <= retries; attempt++) {
48
+ try {
49
+ const res = await fetch(url, {
50
+ method: "POST",
51
+ headers: {
52
+ "Content-Type": "application/json",
53
+ Authorization: `Bearer ${token}`,
54
+ },
55
+ body: JSON.stringify(body),
56
+ });
57
+
58
+ if (AUTH_ERROR_STATUSES.has(res.status)) {
59
+ throw new WeixinAuthError();
60
+ }
61
+
62
+ if (!res.ok) {
63
+ const message = await parseErrorResponse(res);
64
+ throw new Error(`Weixin API error ${res.status}: ${message}`);
65
+ }
66
+
67
+ return res.json();
68
+ } catch (error) {
69
+ if (error instanceof WeixinAuthError) {
70
+ throw error;
71
+ }
72
+
73
+ if (isTransientNetworkError(error) && attempt < retries) {
74
+ continue;
75
+ }
76
+
77
+ if (isTransientNetworkError(error)) {
78
+ throw new WeixinNetworkError(
79
+ error instanceof Error ? error.message : "Network request failed",
80
+ );
81
+ }
82
+
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ throw new WeixinNetworkError("Network request failed");
88
+ }
89
+
90
+ export async function sendTextMessage(to: string, text: string, token: string, baseUrl: string) {
91
+ return weixinRequest(
92
+ "/v1/message/send",
93
+ {
94
+ msg: {
95
+ from_user_id: "",
96
+ to_user_id: to,
97
+ client_id: generateClientId(),
98
+ message_type: 2,
99
+ message_state: 2,
100
+ item_list: [{ type: 1, text_item: { text } }],
101
+ },
102
+ },
103
+ token,
104
+ baseUrl,
105
+ );
106
+ }
107
+
108
+ export async function getContacts(token: string, baseUrl: string) {
109
+ return weixinRequest("/v1/contacts/list", { page_size: 50 }, token, baseUrl);
110
+ }
111
+
112
+ export async function pollMessages(token: string, baseUrl: string, sinceTs?: number) {
113
+ return weixinRequest(
114
+ "/v1/updates/get",
115
+ {
116
+ timeout_ms: 5000,
117
+ ...(sinceTs ? { since_ts: sinceTs } : {}),
118
+ },
119
+ token,
120
+ baseUrl,
121
+ );
122
+ }
123
+
124
+ export async function getChatHistory(to: string, limit: number, token: string, baseUrl: string) {
125
+ return weixinRequest(
126
+ "/v1/message/history",
127
+ { to_user_id: to, limit },
128
+ token,
129
+ baseUrl,
130
+ );
131
+ }
package/src/index.ts ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WeChat MCP Server
4
+ * Exposes WeChat messaging as MCP tools, reusing token from OpenClaw weixin plugin.
5
+ */
6
+
7
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import {
10
+ CallToolRequestSchema,
11
+ ListToolsRequestSchema,
12
+ } from "@modelcontextprotocol/sdk/types.js";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import os from "node:os";
16
+ import {
17
+ DEFAULT_BASE_URL,
18
+ getChatHistory,
19
+ getContacts,
20
+ pollMessages,
21
+ sendTextMessage,
22
+ WeixinAuthError,
23
+ WeixinNetworkError,
24
+ } from "./api.js";
25
+
26
+ // ── Auth / config ──────────────────────────────────────────────────────────
27
+
28
+ const STATE_DIR =
29
+ process.env.OPENCLAW_STATE_DIR?.trim() ||
30
+ process.env.CLAWDBOT_STATE_DIR?.trim() ||
31
+ path.join(os.homedir(), ".openclaw");
32
+
33
+ const WEIXIN_DIR = path.join(STATE_DIR, "openclaw-weixin", "accounts");
34
+
35
+ interface AccountData {
36
+ token?: string;
37
+ baseUrl?: string;
38
+ userId?: string;
39
+ savedAt?: string;
40
+ }
41
+
42
+ function loadAccount(): AccountData & { accountId: string } {
43
+ const files = fs.readdirSync(WEIXIN_DIR).filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json"));
44
+ if (files.length === 0) throw new Error("No WeChat account found. Run: npx @tencent-weixin/openclaw-weixin-cli install");
45
+
46
+ // Pick the first (or explicitly set) account
47
+ const accountId = process.env.WEIXIN_ACCOUNT_ID ?? files[0].replace(".json", "");
48
+ const filePath = path.join(WEIXIN_DIR, `${accountId}.json`);
49
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8")) as AccountData;
50
+ if (!data.token) throw new Error(`No token for account ${accountId}. Re-run QR login.`);
51
+ return { ...data, accountId };
52
+ }
53
+
54
+ // ── Weixin API ─────────────────────────────────────────────────────────────
55
+
56
+ function assertNonEmptyString(value: unknown, field: string): string {
57
+ if (typeof value !== "string" || value.trim() === "") {
58
+ throw new Error(`Invalid argument "${field}": must be a non-empty string`);
59
+ }
60
+ return value.trim();
61
+ }
62
+
63
+ function formatToolError(error: unknown): string {
64
+ if (error instanceof WeixinAuthError) {
65
+ return error.message;
66
+ }
67
+
68
+ if (error instanceof WeixinNetworkError) {
69
+ return `Network error while calling Weixin API: ${error.message}`;
70
+ }
71
+
72
+ if (error instanceof Error) {
73
+ return error.message;
74
+ }
75
+
76
+ return String(error);
77
+ }
78
+
79
+ // ── MCP Server ─────────────────────────────────────────────────────────────
80
+
81
+ const server = new Server(
82
+ { name: "weixin-mcp", version: "1.0.0" },
83
+ { capabilities: { tools: {} } },
84
+ );
85
+
86
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
87
+ tools: [
88
+ {
89
+ name: "weixin_send",
90
+ description: "Send a WeChat message to a user (by user ID or OpenId)",
91
+ inputSchema: {
92
+ type: "object",
93
+ properties: {
94
+ to: { type: "string", description: "Recipient user ID / OpenId" },
95
+ text: { type: "string", description: "Message text to send" },
96
+ },
97
+ required: ["to", "text"],
98
+ },
99
+ },
100
+ {
101
+ name: "weixin_get_contacts",
102
+ description: "List WeChat contacts",
103
+ inputSchema: { type: "object", properties: {} },
104
+ },
105
+ {
106
+ name: "weixin_poll_messages",
107
+ description: "Poll for new WeChat messages since a timestamp",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ since_ts: { type: "number", description: "Unix timestamp in ms (optional)" },
112
+ },
113
+ },
114
+ },
115
+ {
116
+ name: "weixin_get_history",
117
+ description: "Get chat history with a WeChat contact",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: {
121
+ to: { type: "string", description: "Contact user ID / OpenId" },
122
+ limit: { type: "number", description: "Number of messages (default 20)" },
123
+ },
124
+ required: ["to"],
125
+ },
126
+ },
127
+ ],
128
+ }));
129
+
130
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
131
+ const account = loadAccount();
132
+ const { token, baseUrl = DEFAULT_BASE_URL } = account;
133
+
134
+ const { name, arguments: args } = req.params;
135
+
136
+ try {
137
+ let result: unknown;
138
+
139
+ if (name === "weixin_send") {
140
+ const { to, text } = (args ?? {}) as { to?: string; text?: string };
141
+ const validatedTo = assertNonEmptyString(to, "to");
142
+ const validatedText = assertNonEmptyString(text, "text");
143
+ result = await sendTextMessage(validatedTo, validatedText, token!, baseUrl);
144
+ } else if (name === "weixin_get_contacts") {
145
+ result = await getContacts(token!, baseUrl);
146
+ } else if (name === "weixin_poll_messages") {
147
+ const { since_ts } = (args ?? {}) as { since_ts?: number };
148
+ result = await pollMessages(token!, baseUrl, since_ts);
149
+ } else if (name === "weixin_get_history") {
150
+ const { to, limit = 20 } = (args ?? {}) as { to?: string; limit?: number };
151
+ const validatedTo = assertNonEmptyString(to, "to");
152
+ result = await getChatHistory(validatedTo, limit, token!, baseUrl);
153
+ } else {
154
+ throw new Error(`Unknown tool: ${name}`);
155
+ }
156
+
157
+ return {
158
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
159
+ };
160
+ } catch (err) {
161
+ return {
162
+ content: [{ type: "text", text: `Error: ${formatToolError(err)}` }],
163
+ isError: true,
164
+ };
165
+ }
166
+ });
167
+
168
+ // ── Start ──────────────────────────────────────────────────────────────────
169
+
170
+ const transport = new StdioServerTransport();
171
+ await server.connect(transport);
172
+ console.error("WeChat MCP server running on stdio");
package/src/login.ts ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * weixin-login — standalone QR login for weixin-mcp
4
+ * Usage: node dist/login.js
5
+ *
6
+ * Fetches a QR code from Weixin API, renders it in terminal,
7
+ * polls for scan confirmation, then saves token to:
8
+ * ~/.openclaw/openclaw-weixin/accounts/<accountId>.json
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+ import crypto from "node:crypto";
15
+ // @ts-ignore — no types for qrcode-terminal
16
+ import qrcode from "qrcode-terminal";
17
+
18
+ const BASE_URL = "https://ilinkai.weixin.qq.com";
19
+ const BOT_TYPE = "3";
20
+ const STATE_DIR =
21
+ process.env.OPENCLAW_STATE_DIR?.trim() ||
22
+ path.join(os.homedir(), ".openclaw");
23
+ const ACCOUNTS_DIR = path.join(STATE_DIR, "openclaw-weixin", "accounts");
24
+
25
+ async function fetchQRCode(): Promise<{ qrcode: string; qrcode_img_content: string }> {
26
+ const url = `${BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=${BOT_TYPE}`;
27
+ const res = await fetch(url);
28
+ if (!res.ok) throw new Error(`QR fetch failed: ${res.status}`);
29
+ return res.json() as Promise<{ qrcode: string; qrcode_img_content: string }>;
30
+ }
31
+
32
+ async function pollStatus(qrcodeVal: string): Promise<{
33
+ status: "wait" | "scaned" | "confirmed" | "expired";
34
+ bot_token?: string;
35
+ ilink_bot_id?: string;
36
+ baseurl?: string;
37
+ ilink_user_id?: string;
38
+ }> {
39
+ const url = `${BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcodeVal)}`;
40
+ const res = await fetch(url, {
41
+ headers: { "iLink-App-ClientVersion": "1" },
42
+ });
43
+ if (!res.ok) throw new Error(`Status poll failed: ${res.status}`);
44
+ return res.json() as Promise<{
45
+ status: "wait" | "scaned" | "confirmed" | "expired";
46
+ bot_token?: string;
47
+ ilink_bot_id?: string;
48
+ baseurl?: string;
49
+ ilink_user_id?: string;
50
+ }>;
51
+ }
52
+
53
+ function saveAccount(accountId: string, token: string, baseUrl: string, userId?: string) {
54
+ fs.mkdirSync(ACCOUNTS_DIR, { recursive: true });
55
+ const filePath = path.join(ACCOUNTS_DIR, `${accountId}.json`);
56
+ const existing = fs.existsSync(filePath)
57
+ ? (JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<string, unknown>)
58
+ : {};
59
+ fs.writeFileSync(
60
+ filePath,
61
+ JSON.stringify(
62
+ {
63
+ ...existing,
64
+ token,
65
+ baseUrl,
66
+ ...(userId ? { userId } : {}),
67
+ savedAt: new Date().toISOString(),
68
+ },
69
+ null,
70
+ 2,
71
+ ),
72
+ );
73
+ console.log(`\n✅ Token saved to: ${filePath}`);
74
+ }
75
+
76
+ async function main() {
77
+ console.log("🔐 WeChat MCP — QR Login\n");
78
+
79
+ console.log("Fetching QR code...");
80
+ const { qrcode: qrcodeVal } = await fetchQRCode();
81
+
82
+ // Render QR in terminal
83
+ console.log("\nScan this QR code with WeChat:\n");
84
+ qrcode.generate(qrcodeVal, { small: true });
85
+
86
+ console.log("\nWaiting for scan...");
87
+
88
+ // Poll until confirmed or expired
89
+ let attempts = 0;
90
+ while (attempts < 60) {
91
+ await new Promise((r) => setTimeout(r, 2000));
92
+ attempts++;
93
+
94
+ const status = await pollStatus(qrcodeVal);
95
+
96
+ if (status.status === "scaned") {
97
+ process.stdout.write("\r✓ Scanned! Waiting for confirmation...");
98
+ } else if (status.status === "confirmed") {
99
+ const token = status.bot_token;
100
+ if (!token) throw new Error("No token in confirmed response");
101
+ const baseUrl = status.baseurl ?? BASE_URL;
102
+ const userId = status.ilink_user_id ?? status.ilink_bot_id;
103
+ // Use bot_id or a random ID as account identifier
104
+ const accountId = status.ilink_bot_id?.replace("@", "-").replace(".", "-")
105
+ ?? crypto.randomBytes(6).toString("hex") + "-im-bot";
106
+ saveAccount(accountId, token, baseUrl, userId ? `${userId}@im.wechat` : undefined);
107
+ console.log(`\n🎉 Logged in! Account: ${accountId}`);
108
+ console.log(` UserId: ${userId ?? "(unknown)"}`);
109
+ console.log("\nYou can now start the MCP server:");
110
+ console.log(" node dist/index.js\n");
111
+ process.exit(0);
112
+ } else if (status.status === "expired") {
113
+ console.error("\n❌ QR code expired. Please run again.");
114
+ process.exit(1);
115
+ }
116
+ }
117
+
118
+ console.error("\n❌ Timeout waiting for scan.");
119
+ process.exit(1);
120
+ }
121
+
122
+ main().catch((err) => {
123
+ console.error("Error:", err);
124
+ process.exit(1);
125
+ });
@@ -0,0 +1,52 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ test("weixinRequest retries once on transient network error", async () => {
5
+ const { weixinRequest } = await import("../dist/api.js");
6
+ const originalFetch = global.fetch;
7
+ let calls = 0;
8
+
9
+ global.fetch = async () => {
10
+ calls += 1;
11
+ if (calls === 1) {
12
+ throw new TypeError("temporary network failure");
13
+ }
14
+
15
+ return new Response(JSON.stringify({ ok: true }), {
16
+ status: 200,
17
+ headers: { "Content-Type": "application/json" },
18
+ });
19
+ };
20
+
21
+ try {
22
+ const result = await weixinRequest("/v1/test", { hello: "world" }, "token", "https://example.com");
23
+ assert.deepEqual(result, { ok: true });
24
+ assert.equal(calls, 2);
25
+ } finally {
26
+ global.fetch = originalFetch;
27
+ }
28
+ });
29
+
30
+ test("weixinRequest returns auth guidance for expired token", async () => {
31
+ const { weixinRequest, WeixinAuthError } = await import("../dist/api.js");
32
+ const originalFetch = global.fetch;
33
+
34
+ global.fetch = async () =>
35
+ new Response("expired", {
36
+ status: 401,
37
+ headers: { "Content-Type": "text/plain" },
38
+ });
39
+
40
+ try {
41
+ await assert.rejects(
42
+ () => weixinRequest("/v1/test", {}, "token", "https://example.com"),
43
+ (error) => {
44
+ assert.ok(error instanceof WeixinAuthError);
45
+ assert.match(error.message, /Run npm run login to re-authenticate/);
46
+ return true;
47
+ },
48
+ );
49
+ } finally {
50
+ global.fetch = originalFetch;
51
+ }
52
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true
11
+ },
12
+ "include": ["src"]
13
+ }