weixin-mcp 1.2.1 → 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/dist/api.js +2 -4
- package/dist/cli.d.ts +9 -5
- package/dist/cli.js +44 -12
- package/dist/daemon.d.ts +20 -0
- package/dist/daemon.js +122 -0
- package/dist/index.js +2 -5
- package/dist/login.d.ts +3 -5
- package/dist/login.js +4 -9
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +28 -0
- package/dist/server-http.d.ts +7 -0
- package/dist/server-http.js +156 -0
- package/dist/status.js +14 -4
- package/dist/weixin-login.js +0 -0
- package/package.json +4 -2
- package/src/api.ts +2 -5
- package/src/cli.ts +47 -12
- package/src/daemon.ts +136 -0
- package/src/index.ts +2 -7
- package/src/login.ts +4 -10
- package/src/paths.ts +35 -0
- package/src/server-http.ts +183 -0
- package/src/status.ts +14 -7
package/dist/api.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import os from "node:os";
|
|
5
4
|
export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
6
5
|
const CHANNEL_VERSION = "1.0.2";
|
|
7
6
|
export class WeixinAuthError extends Error {
|
|
@@ -35,10 +34,9 @@ function buildHeaders(token, bodyStr) {
|
|
|
35
34
|
};
|
|
36
35
|
}
|
|
37
36
|
// ── Cursor persistence ─────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
path.join(os.homedir(), ".openclaw");
|
|
37
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
40
38
|
function cursorPath(accountId) {
|
|
41
|
-
return path.join(
|
|
39
|
+
return path.join(ACCOUNTS_DIR, `${accountId}.cursor.json`);
|
|
42
40
|
}
|
|
43
41
|
export function loadCursor(accountId) {
|
|
44
42
|
try {
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* weixin-mcp CLI
|
|
3
|
+
* weixin-mcp CLI
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* (no args) Start MCP server in stdio mode (for Claude Desktop)
|
|
7
|
+
* login QR code login
|
|
8
|
+
* status Show account + daemon status
|
|
9
|
+
* start [--port <n>] Start HTTP MCP daemon in background
|
|
10
|
+
* stop Stop daemon
|
|
11
|
+
* restart [--port <n>] Restart daemon
|
|
12
|
+
* logs [-f] Show daemon logs (tail -f with -f flag)
|
|
9
13
|
*/
|
|
10
14
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* weixin-mcp CLI
|
|
3
|
+
* weixin-mcp CLI
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* (no args) Start MCP server in stdio mode (for Claude Desktop)
|
|
7
|
+
* login QR code login
|
|
8
|
+
* status Show account + daemon status
|
|
9
|
+
* start [--port <n>] Start HTTP MCP daemon in background
|
|
10
|
+
* stop Stop daemon
|
|
11
|
+
* restart [--port <n>] Restart daemon
|
|
12
|
+
* logs [-f] Show daemon logs (tail -f with -f flag)
|
|
9
13
|
*/
|
|
10
14
|
const command = process.argv[2];
|
|
11
15
|
if (command === "login") {
|
|
12
|
-
// Run the login flow
|
|
13
16
|
const { main } = await import("./login.js");
|
|
14
17
|
await main();
|
|
15
18
|
}
|
|
@@ -17,16 +20,45 @@ else if (command === "status") {
|
|
|
17
20
|
const { showStatus } = await import("./status.js");
|
|
18
21
|
await showStatus();
|
|
19
22
|
}
|
|
20
|
-
else if (command ===
|
|
21
|
-
|
|
23
|
+
else if (command === "start") {
|
|
24
|
+
const portArg = process.argv.indexOf("--port");
|
|
25
|
+
const port = portArg !== -1 ? Number(process.argv[portArg + 1]) : undefined;
|
|
26
|
+
const { startDaemon } = await import("./daemon.js");
|
|
27
|
+
await startDaemon(port);
|
|
28
|
+
}
|
|
29
|
+
else if (command === "stop") {
|
|
30
|
+
const { stopDaemon } = await import("./daemon.js");
|
|
31
|
+
stopDaemon();
|
|
32
|
+
}
|
|
33
|
+
else if (command === "restart") {
|
|
34
|
+
const portArg = process.argv.indexOf("--port");
|
|
35
|
+
const port = portArg !== -1 ? Number(process.argv[portArg + 1]) : undefined;
|
|
36
|
+
const { restartDaemon } = await import("./daemon.js");
|
|
37
|
+
await restartDaemon(port);
|
|
38
|
+
}
|
|
39
|
+
else if (command === "logs") {
|
|
40
|
+
const follow = process.argv.includes("-f") || process.argv.includes("--follow");
|
|
41
|
+
const { showLogs } = await import("./daemon.js");
|
|
42
|
+
showLogs(follow);
|
|
43
|
+
}
|
|
44
|
+
else if (command === undefined || command === "serve") {
|
|
45
|
+
// Default: stdio MCP server (for Claude Desktop integration)
|
|
22
46
|
await import("./index.js");
|
|
23
47
|
}
|
|
24
48
|
else {
|
|
25
49
|
console.error(`Unknown command: ${command}`);
|
|
26
|
-
console.error(`
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
50
|
+
console.error(`
|
|
51
|
+
Usage: npx weixin-mcp [command]
|
|
52
|
+
|
|
53
|
+
Commands:
|
|
54
|
+
(no args) Start stdio MCP server (Claude Desktop mode)
|
|
55
|
+
login QR code login
|
|
56
|
+
status Show account and daemon status
|
|
57
|
+
start [--port n] Start HTTP MCP daemon in background (default port: 3001)
|
|
58
|
+
stop Stop daemon
|
|
59
|
+
restart Restart daemon
|
|
60
|
+
logs [-f] Show daemon logs (-f to follow)
|
|
61
|
+
`);
|
|
30
62
|
process.exit(1);
|
|
31
63
|
}
|
|
32
64
|
export {};
|
package/dist/daemon.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon management for weixin-mcp HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Daemon runs as a child process, writing its PID and port to ~/.weixin-mcp/daemon.json.
|
|
5
|
+
* All daemon output is appended to ~/.weixin-mcp/daemon.log.
|
|
6
|
+
*/
|
|
7
|
+
interface DaemonInfo {
|
|
8
|
+
pid: number;
|
|
9
|
+
port: number;
|
|
10
|
+
startedAt: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function daemonStatus(): {
|
|
13
|
+
running: boolean;
|
|
14
|
+
info: DaemonInfo | null;
|
|
15
|
+
};
|
|
16
|
+
export declare function startDaemon(port?: number): Promise<void>;
|
|
17
|
+
export declare function stopDaemon(): void;
|
|
18
|
+
export declare function restartDaemon(port?: number): Promise<void>;
|
|
19
|
+
export declare function showLogs(follow?: boolean): void;
|
|
20
|
+
export {};
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon management for weixin-mcp HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Daemon runs as a child process, writing its PID and port to ~/.weixin-mcp/daemon.json.
|
|
5
|
+
* All daemon output is appended to ~/.weixin-mcp/daemon.log.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
const DATA_DIR = path.join(os.homedir(), ".weixin-mcp");
|
|
13
|
+
const PID_FILE = path.join(DATA_DIR, "daemon.json");
|
|
14
|
+
const LOG_FILE = path.join(DATA_DIR, "daemon.log");
|
|
15
|
+
const DEFAULT_PORT = 3001;
|
|
16
|
+
function readDaemonInfo() {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(PID_FILE, "utf-8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isRunning(pid) {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function daemonStatus() {
|
|
34
|
+
const info = readDaemonInfo();
|
|
35
|
+
if (!info)
|
|
36
|
+
return { running: false, info: null };
|
|
37
|
+
const running = isRunning(info.pid);
|
|
38
|
+
if (!running) {
|
|
39
|
+
// stale PID file
|
|
40
|
+
try {
|
|
41
|
+
fs.unlinkSync(PID_FILE);
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
}
|
|
45
|
+
return { running, info: running ? info : null };
|
|
46
|
+
}
|
|
47
|
+
export async function startDaemon(port = DEFAULT_PORT) {
|
|
48
|
+
const { running, info } = daemonStatus();
|
|
49
|
+
if (running && info) {
|
|
50
|
+
console.log(`⚠️ Daemon already running (pid ${info.pid}, port ${info.port})`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
54
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
55
|
+
const __dirname = path.dirname(__filename);
|
|
56
|
+
const serverScript = path.join(__dirname, "server-http.js");
|
|
57
|
+
const logFd = fs.openSync(LOG_FILE, "a");
|
|
58
|
+
const child = spawn(process.execPath, [serverScript, String(port)], {
|
|
59
|
+
detached: true,
|
|
60
|
+
stdio: ["ignore", logFd, logFd],
|
|
61
|
+
env: { ...process.env, WEIXIN_MCP_PORT: String(port) },
|
|
62
|
+
});
|
|
63
|
+
child.unref();
|
|
64
|
+
fs.closeSync(logFd);
|
|
65
|
+
// Wait briefly for the process to start
|
|
66
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
67
|
+
if (!isRunning(child.pid)) {
|
|
68
|
+
console.error("❌ Daemon failed to start. Check logs:");
|
|
69
|
+
console.error(` npx weixin-mcp logs`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
const daemonInfo = {
|
|
73
|
+
pid: child.pid,
|
|
74
|
+
port,
|
|
75
|
+
startedAt: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
fs.writeFileSync(PID_FILE, JSON.stringify(daemonInfo, null, 2));
|
|
78
|
+
console.log(`✅ weixin-mcp daemon started`);
|
|
79
|
+
console.log(` PID: ${child.pid}`);
|
|
80
|
+
console.log(` Port: ${port}`);
|
|
81
|
+
console.log(` URL: http://localhost:${port}/mcp`);
|
|
82
|
+
console.log(` Logs: ${LOG_FILE}`);
|
|
83
|
+
}
|
|
84
|
+
export function stopDaemon() {
|
|
85
|
+
const { running, info } = daemonStatus();
|
|
86
|
+
if (!running || !info) {
|
|
87
|
+
console.log("ℹ️ Daemon is not running.");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
process.kill(info.pid, "SIGTERM");
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(PID_FILE);
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
console.log(`✅ Daemon stopped (pid ${info.pid})`);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
console.error(`❌ Failed to stop daemon: ${String(err)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function restartDaemon(port) {
|
|
103
|
+
const { info } = daemonStatus();
|
|
104
|
+
stopDaemon();
|
|
105
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
106
|
+
await startDaemon(port ?? info?.port ?? DEFAULT_PORT);
|
|
107
|
+
}
|
|
108
|
+
export function showLogs(follow = false) {
|
|
109
|
+
if (!fs.existsSync(LOG_FILE)) {
|
|
110
|
+
console.log("No log file yet. Start the daemon first:");
|
|
111
|
+
console.log(" npx weixin-mcp start");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (follow) {
|
|
115
|
+
const tail = spawn("tail", ["-f", LOG_FILE], { stdio: "inherit" });
|
|
116
|
+
process.on("SIGINT", () => tail.kill());
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const lines = fs.readFileSync(LOG_FILE, "utf-8").split("\n").slice(-50).join("\n");
|
|
120
|
+
console.log(lines);
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -8,13 +8,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
-
import os from "node:os";
|
|
12
11
|
import { DEFAULT_BASE_URL, getUpdates, getConfig, sendTextMessage, loadCursor, saveCursor, WeixinAuthError, WeixinNetworkError, } from "./api.js";
|
|
12
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
13
13
|
// ── Auth / config ──────────────────────────────────────────────────────────
|
|
14
|
-
const
|
|
15
|
-
process.env.CLAWDBOT_STATE_DIR?.trim() ||
|
|
16
|
-
path.join(os.homedir(), ".openclaw");
|
|
17
|
-
const WEIXIN_DIR = path.join(STATE_DIR, "openclaw-weixin", "accounts");
|
|
14
|
+
const WEIXIN_DIR = ACCOUNTS_DIR;
|
|
18
15
|
function loadAccount() {
|
|
19
16
|
const files = fs
|
|
20
17
|
.readdirSync(WEIXIN_DIR)
|
package/dist/login.d.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* weixin-login —
|
|
4
|
-
* Usage:
|
|
3
|
+
* weixin-login — QR login for weixin-mcp
|
|
4
|
+
* Usage: npx weixin-mcp login
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* polls for scan confirmation, then saves token to:
|
|
8
|
-
* ~/.openclaw/openclaw-weixin/accounts/<accountId>.json
|
|
6
|
+
* Token saved to: ~/.weixin-mcp/accounts/ (or ~/.openclaw/openclaw-weixin/accounts/ if OpenClaw is installed)
|
|
9
7
|
*/
|
|
10
8
|
export declare function main(): Promise<void>;
|
package/dist/login.js
CHANGED
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* weixin-login —
|
|
4
|
-
* Usage:
|
|
3
|
+
* weixin-login — QR login for weixin-mcp
|
|
4
|
+
* Usage: npx weixin-mcp login
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* polls for scan confirmation, then saves token to:
|
|
8
|
-
* ~/.openclaw/openclaw-weixin/accounts/<accountId>.json
|
|
6
|
+
* Token saved to: ~/.weixin-mcp/accounts/ (or ~/.openclaw/openclaw-weixin/accounts/ if OpenClaw is installed)
|
|
9
7
|
*/
|
|
10
8
|
import fs from "node:fs";
|
|
11
9
|
import path from "node:path";
|
|
12
|
-
import os from "node:os";
|
|
13
10
|
import crypto from "node:crypto";
|
|
14
11
|
// @ts-ignore — no types for qrcode-terminal
|
|
15
12
|
import qrcode from "qrcode-terminal";
|
|
13
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
16
14
|
const BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
17
15
|
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
16
|
async function fetchQRCode() {
|
|
22
17
|
const base = BASE_URL.endsWith("/") ? BASE_URL : `${BASE_URL}/`;
|
|
23
18
|
const url = `${base}ilink/bot/get_bot_qrcode?bot_type=${BOT_TYPE}`;
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the accounts directory.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1. WEIXIN_MCP_DIR env var (explicit override)
|
|
6
|
+
* 2. If OpenClaw state dir exists → ~/.openclaw/openclaw-weixin/accounts/ (backward compat)
|
|
7
|
+
* 3. Default → ~/.weixin-mcp/accounts/
|
|
8
|
+
*/
|
|
9
|
+
export declare const ACCOUNTS_DIR: string;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the accounts directory.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1. WEIXIN_MCP_DIR env var (explicit override)
|
|
6
|
+
* 2. If OpenClaw state dir exists → ~/.openclaw/openclaw-weixin/accounts/ (backward compat)
|
|
7
|
+
* 3. Default → ~/.weixin-mcp/accounts/
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
function resolveAccountsDir() {
|
|
13
|
+
// Explicit override
|
|
14
|
+
if (process.env.WEIXIN_MCP_DIR?.trim()) {
|
|
15
|
+
return process.env.WEIXIN_MCP_DIR.trim();
|
|
16
|
+
}
|
|
17
|
+
// OpenClaw compat: if the OpenClaw weixin accounts dir exists, use it
|
|
18
|
+
const openclawStateDir = process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
19
|
+
process.env.CLAWDBOT_STATE_DIR?.trim() ||
|
|
20
|
+
path.join(os.homedir(), ".openclaw");
|
|
21
|
+
const openclawAccountsDir = path.join(openclawStateDir, "openclaw-weixin", "accounts");
|
|
22
|
+
if (fs.existsSync(openclawAccountsDir)) {
|
|
23
|
+
return openclawAccountsDir;
|
|
24
|
+
}
|
|
25
|
+
// Default: standalone ~/.weixin-mcp/accounts/
|
|
26
|
+
return path.join(os.homedir(), ".weixin-mcp", "accounts");
|
|
27
|
+
}
|
|
28
|
+
export const ACCOUNTS_DIR = resolveAccountsDir();
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP MCP server — runs as a daemon process.
|
|
3
|
+
* Spawned by `weixin-mcp start`, listens on a given port.
|
|
4
|
+
*
|
|
5
|
+
* Clients connect via: http://localhost:<port>/mcp
|
|
6
|
+
*/
|
|
7
|
+
import express from "express";
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
10
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
// Reuse the same tool definitions and handlers from the main server
|
|
13
|
+
import { DEFAULT_BASE_URL, getUpdates, getConfig, sendTextMessage, loadCursor, saveCursor, WeixinAuthError, WeixinNetworkError, } from "./api.js";
|
|
14
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
const port = Number(process.env.WEIXIN_MCP_PORT ?? process.argv[2] ?? 3001);
|
|
18
|
+
function loadAccount() {
|
|
19
|
+
const files = fs.readdirSync(ACCOUNTS_DIR).filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json") && !f.endsWith(".cursor.json"));
|
|
20
|
+
if (files.length === 0)
|
|
21
|
+
throw new Error("No WeChat account. Run: npx weixin-mcp login");
|
|
22
|
+
const accountId = process.env.WEIXIN_ACCOUNT_ID ?? files[0].replace(".json", "");
|
|
23
|
+
const data = JSON.parse(fs.readFileSync(path.join(ACCOUNTS_DIR, `${accountId}.json`), "utf-8"));
|
|
24
|
+
if (!data.token)
|
|
25
|
+
throw new Error(`No token for ${accountId}. Run: npx weixin-mcp login`);
|
|
26
|
+
return { ...data, accountId };
|
|
27
|
+
}
|
|
28
|
+
function assertStr(v, f) {
|
|
29
|
+
if (typeof v !== "string" || !v.trim())
|
|
30
|
+
throw new Error(`"${f}" must be a non-empty string`);
|
|
31
|
+
return v.trim();
|
|
32
|
+
}
|
|
33
|
+
function fmtErr(e) {
|
|
34
|
+
if (e instanceof WeixinAuthError || e instanceof WeixinNetworkError || e instanceof Error)
|
|
35
|
+
return e.message;
|
|
36
|
+
return String(e);
|
|
37
|
+
}
|
|
38
|
+
// ── MCP server factory ─────────────────────────────────────────────────────
|
|
39
|
+
function createMCPServer() {
|
|
40
|
+
const server = new Server({ name: "weixin-mcp", version: "1.2.2" }, { capabilities: { tools: {} } });
|
|
41
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
42
|
+
tools: [
|
|
43
|
+
{
|
|
44
|
+
name: "weixin_send",
|
|
45
|
+
description: "Send a WeChat text message. Pass context_token from a received message to link the reply.",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
to: { type: "string" },
|
|
50
|
+
text: { type: "string" },
|
|
51
|
+
context_token: { type: "string" },
|
|
52
|
+
},
|
|
53
|
+
required: ["to", "text"],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "weixin_poll",
|
|
58
|
+
description: "Poll for new WeChat messages (cursor-based, no duplicates).",
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: { reset_cursor: { type: "boolean" } },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "weixin_get_config",
|
|
66
|
+
description: "Get user config (typing ticket, etc.).",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
user_id: { type: "string" },
|
|
71
|
+
context_token: { type: "string" },
|
|
72
|
+
},
|
|
73
|
+
required: ["user_id"],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
}));
|
|
78
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
79
|
+
const { token, baseUrl = DEFAULT_BASE_URL, accountId } = loadAccount();
|
|
80
|
+
const { name, arguments: args } = req.params;
|
|
81
|
+
try {
|
|
82
|
+
let result;
|
|
83
|
+
if (name === "weixin_send") {
|
|
84
|
+
const a = (args ?? {});
|
|
85
|
+
result = await sendTextMessage(assertStr(a.to, "to"), assertStr(a.text, "text"), token, baseUrl, a.context_token);
|
|
86
|
+
}
|
|
87
|
+
else if (name === "weixin_poll") {
|
|
88
|
+
const { reset_cursor } = (args ?? {});
|
|
89
|
+
const cursor = reset_cursor ? "" : loadCursor(accountId);
|
|
90
|
+
const resp = await getUpdates(token, baseUrl, cursor);
|
|
91
|
+
if (resp.get_updates_buf)
|
|
92
|
+
saveCursor(accountId, resp.get_updates_buf);
|
|
93
|
+
result = resp;
|
|
94
|
+
}
|
|
95
|
+
else if (name === "weixin_get_config") {
|
|
96
|
+
const a = (args ?? {});
|
|
97
|
+
result = await getConfig(assertStr(a.user_id, "user_id"), token, baseUrl, a.context_token);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
101
|
+
}
|
|
102
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return { content: [{ type: "text", text: `Error: ${fmtErr(err)}` }], isError: true };
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return server;
|
|
109
|
+
}
|
|
110
|
+
// ── Express HTTP server ────────────────────────────────────────────────────
|
|
111
|
+
const app = express();
|
|
112
|
+
app.use(express.json());
|
|
113
|
+
// Session store for stateful transports
|
|
114
|
+
const sessions = new Map();
|
|
115
|
+
app.post("/mcp", async (req, res) => {
|
|
116
|
+
// Check if this is an existing session
|
|
117
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
118
|
+
let transport = sessionId ? sessions.get(sessionId) : undefined;
|
|
119
|
+
if (!transport) {
|
|
120
|
+
// New session
|
|
121
|
+
const newSessionId = randomUUID();
|
|
122
|
+
transport = new StreamableHTTPServerTransport({
|
|
123
|
+
sessionIdGenerator: () => newSessionId,
|
|
124
|
+
onsessioninitialized: (id) => { sessions.set(id, transport); },
|
|
125
|
+
});
|
|
126
|
+
transport.onclose = () => { sessions.delete(newSessionId); };
|
|
127
|
+
const server = createMCPServer();
|
|
128
|
+
await server.connect(transport);
|
|
129
|
+
}
|
|
130
|
+
await transport.handleRequest(req, res, req.body);
|
|
131
|
+
});
|
|
132
|
+
app.get("/mcp", async (req, res) => {
|
|
133
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
134
|
+
const transport = sessionId ? sessions.get(sessionId) : undefined;
|
|
135
|
+
if (!transport) {
|
|
136
|
+
res.status(404).json({ error: "Session not found" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
await transport.handleRequest(req, res);
|
|
140
|
+
});
|
|
141
|
+
app.delete("/mcp", async (req, res) => {
|
|
142
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
143
|
+
const transport = sessionId ? sessions.get(sessionId) : undefined;
|
|
144
|
+
if (!transport) {
|
|
145
|
+
res.status(404).json({ error: "Session not found" });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
await transport.handleRequest(req, res);
|
|
149
|
+
});
|
|
150
|
+
app.get("/health", (_req, res) => {
|
|
151
|
+
res.json({ status: "ok", port, sessions: sessions.size });
|
|
152
|
+
});
|
|
153
|
+
app.listen(port, () => {
|
|
154
|
+
console.log(`[weixin-mcp] HTTP MCP server listening on port ${port}`);
|
|
155
|
+
console.log(`[weixin-mcp] MCP endpoint: http://localhost:${port}/mcp`);
|
|
156
|
+
});
|
package/dist/status.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
path.join(os.homedir(), ".openclaw");
|
|
6
|
-
const ACCOUNTS_DIR = path.join(STATE_DIR, "openclaw-weixin", "accounts");
|
|
3
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
4
|
+
import { daemonStatus } from "./daemon.js";
|
|
7
5
|
export async function showStatus() {
|
|
8
6
|
console.log("🔍 weixin-mcp status\n");
|
|
7
|
+
// Daemon status
|
|
8
|
+
const { running, info } = daemonStatus();
|
|
9
|
+
if (running && info) {
|
|
10
|
+
console.log(`🟢 Daemon: running (pid ${info.pid}, port ${info.port})`);
|
|
11
|
+
console.log(` URL: http://localhost:${info.port}/mcp`);
|
|
12
|
+
console.log(` Started: ${new Date(info.startedAt).toLocaleString()}`);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
console.log("⚫ Daemon: not running");
|
|
16
|
+
console.log(" Tip: npx weixin-mcp start");
|
|
17
|
+
}
|
|
18
|
+
console.log();
|
|
9
19
|
let files;
|
|
10
20
|
try {
|
|
11
21
|
files = fs.readdirSync(ACCOUNTS_DIR).filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json") && !f.endsWith(".cursor.json"));
|
package/dist/weixin-login.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weixin-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "MCP server for WeChat (Weixin) — send messages via OpenClaw weixin plugin auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
"weixin-login": "dist/weixin-login.js"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
20
|
+
"express": "^5.2.1",
|
|
20
21
|
"qrcode-terminal": "^0.12.0"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
24
|
+
"@types/express": "^5.0.6",
|
|
23
25
|
"@types/node": "^22.0.0",
|
|
24
26
|
"typescript": "^5.0.0"
|
|
25
27
|
}
|
package/src/api.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import os from "node:os";
|
|
5
4
|
|
|
6
5
|
export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
7
6
|
const CHANNEL_VERSION = "1.0.2";
|
|
@@ -44,12 +43,10 @@ function buildHeaders(token: string, bodyStr: string): Record<string, string> {
|
|
|
44
43
|
|
|
45
44
|
// ── Cursor persistence ─────────────────────────────────────────────────────
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
49
|
-
path.join(os.homedir(), ".openclaw");
|
|
46
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
50
47
|
|
|
51
48
|
function cursorPath(accountId: string): string {
|
|
52
|
-
return path.join(
|
|
49
|
+
return path.join(ACCOUNTS_DIR, `${accountId}.cursor.json`);
|
|
53
50
|
}
|
|
54
51
|
|
|
55
52
|
export function loadCursor(accountId: string): string {
|
package/src/cli.ts
CHANGED
|
@@ -1,30 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* weixin-mcp CLI
|
|
3
|
+
* weixin-mcp CLI
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* (no args) Start MCP server in stdio mode (for Claude Desktop)
|
|
7
|
+
* login QR code login
|
|
8
|
+
* status Show account + daemon status
|
|
9
|
+
* start [--port <n>] Start HTTP MCP daemon in background
|
|
10
|
+
* stop Stop daemon
|
|
11
|
+
* restart [--port <n>] Restart daemon
|
|
12
|
+
* logs [-f] Show daemon logs (tail -f with -f flag)
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
15
|
const command = process.argv[2];
|
|
12
16
|
|
|
13
17
|
if (command === "login") {
|
|
14
|
-
// Run the login flow
|
|
15
18
|
const { main } = await import("./login.js");
|
|
16
19
|
await main();
|
|
20
|
+
|
|
17
21
|
} else if (command === "status") {
|
|
18
22
|
const { showStatus } = await import("./status.js");
|
|
19
23
|
await showStatus();
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
|
|
25
|
+
} else if (command === "start") {
|
|
26
|
+
const portArg = process.argv.indexOf("--port");
|
|
27
|
+
const port = portArg !== -1 ? Number(process.argv[portArg + 1]) : undefined;
|
|
28
|
+
const { startDaemon } = await import("./daemon.js");
|
|
29
|
+
await startDaemon(port);
|
|
30
|
+
|
|
31
|
+
} else if (command === "stop") {
|
|
32
|
+
const { stopDaemon } = await import("./daemon.js");
|
|
33
|
+
stopDaemon();
|
|
34
|
+
|
|
35
|
+
} else if (command === "restart") {
|
|
36
|
+
const portArg = process.argv.indexOf("--port");
|
|
37
|
+
const port = portArg !== -1 ? Number(process.argv[portArg + 1]) : undefined;
|
|
38
|
+
const { restartDaemon } = await import("./daemon.js");
|
|
39
|
+
await restartDaemon(port);
|
|
40
|
+
|
|
41
|
+
} else if (command === "logs") {
|
|
42
|
+
const follow = process.argv.includes("-f") || process.argv.includes("--follow");
|
|
43
|
+
const { showLogs } = await import("./daemon.js");
|
|
44
|
+
showLogs(follow);
|
|
45
|
+
|
|
46
|
+
} else if (command === undefined || command === "serve") {
|
|
47
|
+
// Default: stdio MCP server (for Claude Desktop integration)
|
|
22
48
|
await import("./index.js");
|
|
49
|
+
|
|
23
50
|
} else {
|
|
24
51
|
console.error(`Unknown command: ${command}`);
|
|
25
|
-
console.error(`
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
52
|
+
console.error(`
|
|
53
|
+
Usage: npx weixin-mcp [command]
|
|
54
|
+
|
|
55
|
+
Commands:
|
|
56
|
+
(no args) Start stdio MCP server (Claude Desktop mode)
|
|
57
|
+
login QR code login
|
|
58
|
+
status Show account and daemon status
|
|
59
|
+
start [--port n] Start HTTP MCP daemon in background (default port: 3001)
|
|
60
|
+
stop Stop daemon
|
|
61
|
+
restart Restart daemon
|
|
62
|
+
logs [-f] Show daemon logs (-f to follow)
|
|
63
|
+
`);
|
|
29
64
|
process.exit(1);
|
|
30
65
|
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon management for weixin-mcp HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Daemon runs as a child process, writing its PID and port to ~/.weixin-mcp/daemon.json.
|
|
5
|
+
* All daemon output is appended to ~/.weixin-mcp/daemon.log.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const DATA_DIR = path.join(os.homedir(), ".weixin-mcp");
|
|
15
|
+
const PID_FILE = path.join(DATA_DIR, "daemon.json");
|
|
16
|
+
const LOG_FILE = path.join(DATA_DIR, "daemon.log");
|
|
17
|
+
const DEFAULT_PORT = 3001;
|
|
18
|
+
|
|
19
|
+
interface DaemonInfo {
|
|
20
|
+
pid: number;
|
|
21
|
+
port: number;
|
|
22
|
+
startedAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readDaemonInfo(): DaemonInfo | null {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(fs.readFileSync(PID_FILE, "utf-8")) as DaemonInfo;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isRunning(pid: number): boolean {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function daemonStatus(): { running: boolean; info: DaemonInfo | null } {
|
|
43
|
+
const info = readDaemonInfo();
|
|
44
|
+
if (!info) return { running: false, info: null };
|
|
45
|
+
const running = isRunning(info.pid);
|
|
46
|
+
if (!running) {
|
|
47
|
+
// stale PID file
|
|
48
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
49
|
+
}
|
|
50
|
+
return { running, info: running ? info : null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function startDaemon(port = DEFAULT_PORT): Promise<void> {
|
|
54
|
+
const { running, info } = daemonStatus();
|
|
55
|
+
if (running && info) {
|
|
56
|
+
console.log(`⚠️ Daemon already running (pid ${info.pid}, port ${info.port})`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
61
|
+
|
|
62
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
63
|
+
const __dirname = path.dirname(__filename);
|
|
64
|
+
const serverScript = path.join(__dirname, "server-http.js");
|
|
65
|
+
|
|
66
|
+
const logFd = fs.openSync(LOG_FILE, "a");
|
|
67
|
+
const child = spawn(process.execPath, [serverScript, String(port)], {
|
|
68
|
+
detached: true,
|
|
69
|
+
stdio: ["ignore", logFd, logFd],
|
|
70
|
+
env: { ...process.env, WEIXIN_MCP_PORT: String(port) },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
child.unref();
|
|
74
|
+
fs.closeSync(logFd);
|
|
75
|
+
|
|
76
|
+
// Wait briefly for the process to start
|
|
77
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
78
|
+
|
|
79
|
+
if (!isRunning(child.pid!)) {
|
|
80
|
+
console.error("❌ Daemon failed to start. Check logs:");
|
|
81
|
+
console.error(` npx weixin-mcp logs`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const daemonInfo: DaemonInfo = {
|
|
86
|
+
pid: child.pid!,
|
|
87
|
+
port,
|
|
88
|
+
startedAt: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
fs.writeFileSync(PID_FILE, JSON.stringify(daemonInfo, null, 2));
|
|
91
|
+
|
|
92
|
+
console.log(`✅ weixin-mcp daemon started`);
|
|
93
|
+
console.log(` PID: ${child.pid}`);
|
|
94
|
+
console.log(` Port: ${port}`);
|
|
95
|
+
console.log(` URL: http://localhost:${port}/mcp`);
|
|
96
|
+
console.log(` Logs: ${LOG_FILE}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function stopDaemon(): void {
|
|
100
|
+
const { running, info } = daemonStatus();
|
|
101
|
+
if (!running || !info) {
|
|
102
|
+
console.log("ℹ️ Daemon is not running.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
process.kill(info.pid, "SIGTERM");
|
|
108
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
109
|
+
console.log(`✅ Daemon stopped (pid ${info.pid})`);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error(`❌ Failed to stop daemon: ${String(err)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function restartDaemon(port?: number): Promise<void> {
|
|
116
|
+
const { info } = daemonStatus();
|
|
117
|
+
stopDaemon();
|
|
118
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
119
|
+
await startDaemon(port ?? info?.port ?? DEFAULT_PORT);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function showLogs(follow = false): void {
|
|
123
|
+
if (!fs.existsSync(LOG_FILE)) {
|
|
124
|
+
console.log("No log file yet. Start the daemon first:");
|
|
125
|
+
console.log(" npx weixin-mcp start");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (follow) {
|
|
130
|
+
const tail = spawn("tail", ["-f", LOG_FILE], { stdio: "inherit" });
|
|
131
|
+
process.on("SIGINT", () => tail.kill());
|
|
132
|
+
} else {
|
|
133
|
+
const lines = fs.readFileSync(LOG_FILE, "utf-8").split("\n").slice(-50).join("\n");
|
|
134
|
+
console.log(lines);
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
13
13
|
import fs from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
|
-
import os from "node:os";
|
|
16
15
|
import {
|
|
17
16
|
DEFAULT_BASE_URL,
|
|
18
17
|
getUpdates,
|
|
@@ -23,15 +22,11 @@ import {
|
|
|
23
22
|
WeixinAuthError,
|
|
24
23
|
WeixinNetworkError,
|
|
25
24
|
} from "./api.js";
|
|
25
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
26
26
|
|
|
27
27
|
// ── Auth / config ──────────────────────────────────────────────────────────
|
|
28
28
|
|
|
29
|
-
const
|
|
30
|
-
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
31
|
-
process.env.CLAWDBOT_STATE_DIR?.trim() ||
|
|
32
|
-
path.join(os.homedir(), ".openclaw");
|
|
33
|
-
|
|
34
|
-
const WEIXIN_DIR = path.join(STATE_DIR, "openclaw-weixin", "accounts");
|
|
29
|
+
const WEIXIN_DIR = ACCOUNTS_DIR;
|
|
35
30
|
|
|
36
31
|
interface AccountData {
|
|
37
32
|
token?: string;
|
package/src/login.ts
CHANGED
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* weixin-login —
|
|
4
|
-
* Usage:
|
|
3
|
+
* weixin-login — QR login for weixin-mcp
|
|
4
|
+
* Usage: npx weixin-mcp login
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* polls for scan confirmation, then saves token to:
|
|
8
|
-
* ~/.openclaw/openclaw-weixin/accounts/<accountId>.json
|
|
6
|
+
* Token saved to: ~/.weixin-mcp/accounts/ (or ~/.openclaw/openclaw-weixin/accounts/ if OpenClaw is installed)
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import fs from "node:fs";
|
|
12
10
|
import path from "node:path";
|
|
13
|
-
import os from "node:os";
|
|
14
11
|
import crypto from "node:crypto";
|
|
15
12
|
// @ts-ignore — no types for qrcode-terminal
|
|
16
13
|
import qrcode from "qrcode-terminal";
|
|
14
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
17
15
|
|
|
18
16
|
const BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
19
17
|
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
18
|
|
|
25
19
|
async function fetchQRCode(): Promise<{ qrcode: string; qrcode_img_content: string }> {
|
|
26
20
|
const base = BASE_URL.endsWith("/") ? BASE_URL : `${BASE_URL}/`;
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the accounts directory.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1. WEIXIN_MCP_DIR env var (explicit override)
|
|
6
|
+
* 2. If OpenClaw state dir exists → ~/.openclaw/openclaw-weixin/accounts/ (backward compat)
|
|
7
|
+
* 3. Default → ~/.weixin-mcp/accounts/
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
|
|
14
|
+
function resolveAccountsDir(): string {
|
|
15
|
+
// Explicit override
|
|
16
|
+
if (process.env.WEIXIN_MCP_DIR?.trim()) {
|
|
17
|
+
return process.env.WEIXIN_MCP_DIR.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// OpenClaw compat: if the OpenClaw weixin accounts dir exists, use it
|
|
21
|
+
const openclawStateDir =
|
|
22
|
+
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
23
|
+
process.env.CLAWDBOT_STATE_DIR?.trim() ||
|
|
24
|
+
path.join(os.homedir(), ".openclaw");
|
|
25
|
+
|
|
26
|
+
const openclawAccountsDir = path.join(openclawStateDir, "openclaw-weixin", "accounts");
|
|
27
|
+
if (fs.existsSync(openclawAccountsDir)) {
|
|
28
|
+
return openclawAccountsDir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Default: standalone ~/.weixin-mcp/accounts/
|
|
32
|
+
return path.join(os.homedir(), ".weixin-mcp", "accounts");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const ACCOUNTS_DIR = resolveAccountsDir();
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP MCP server — runs as a daemon process.
|
|
3
|
+
* Spawned by `weixin-mcp start`, listens on a given port.
|
|
4
|
+
*
|
|
5
|
+
* Clients connect via: http://localhost:<port>/mcp
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import express from "express";
|
|
9
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
10
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
11
|
+
import {
|
|
12
|
+
CallToolRequestSchema,
|
|
13
|
+
ListToolsRequestSchema,
|
|
14
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
// Reuse the same tool definitions and handlers from the main server
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_BASE_URL,
|
|
20
|
+
getUpdates,
|
|
21
|
+
getConfig,
|
|
22
|
+
sendTextMessage,
|
|
23
|
+
loadCursor,
|
|
24
|
+
saveCursor,
|
|
25
|
+
WeixinAuthError,
|
|
26
|
+
WeixinNetworkError,
|
|
27
|
+
} from "./api.js";
|
|
28
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
const port = Number(process.env.WEIXIN_MCP_PORT ?? process.argv[2] ?? 3001);
|
|
33
|
+
|
|
34
|
+
// ── Account loader ─────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
interface AccountData { token?: string; baseUrl?: string; userId?: string }
|
|
37
|
+
|
|
38
|
+
function loadAccount(): AccountData & { accountId: string } {
|
|
39
|
+
const files = fs.readdirSync(ACCOUNTS_DIR).filter(
|
|
40
|
+
(f) => f.endsWith(".json") && !f.endsWith(".sync.json") && !f.endsWith(".cursor.json"),
|
|
41
|
+
);
|
|
42
|
+
if (files.length === 0) throw new Error("No WeChat account. Run: npx weixin-mcp login");
|
|
43
|
+
const accountId = process.env.WEIXIN_ACCOUNT_ID ?? files[0].replace(".json", "");
|
|
44
|
+
const data = JSON.parse(fs.readFileSync(path.join(ACCOUNTS_DIR, `${accountId}.json`), "utf-8")) as AccountData;
|
|
45
|
+
if (!data.token) throw new Error(`No token for ${accountId}. Run: npx weixin-mcp login`);
|
|
46
|
+
return { ...data, accountId };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assertStr(v: unknown, f: string): string {
|
|
50
|
+
if (typeof v !== "string" || !v.trim()) throw new Error(`"${f}" must be a non-empty string`);
|
|
51
|
+
return v.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fmtErr(e: unknown): string {
|
|
55
|
+
if (e instanceof WeixinAuthError || e instanceof WeixinNetworkError || e instanceof Error) return e.message;
|
|
56
|
+
return String(e);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── MCP server factory ─────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function createMCPServer() {
|
|
62
|
+
const server = new Server(
|
|
63
|
+
{ name: "weixin-mcp", version: "1.2.2" },
|
|
64
|
+
{ capabilities: { tools: {} } },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
68
|
+
tools: [
|
|
69
|
+
{
|
|
70
|
+
name: "weixin_send",
|
|
71
|
+
description: "Send a WeChat text message. Pass context_token from a received message to link the reply.",
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
to: { type: "string" },
|
|
76
|
+
text: { type: "string" },
|
|
77
|
+
context_token: { type: "string" },
|
|
78
|
+
},
|
|
79
|
+
required: ["to", "text"],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "weixin_poll",
|
|
84
|
+
description: "Poll for new WeChat messages (cursor-based, no duplicates).",
|
|
85
|
+
inputSchema: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: { reset_cursor: { type: "boolean" } },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "weixin_get_config",
|
|
92
|
+
description: "Get user config (typing ticket, etc.).",
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: "object",
|
|
95
|
+
properties: {
|
|
96
|
+
user_id: { type: "string" },
|
|
97
|
+
context_token: { type: "string" },
|
|
98
|
+
},
|
|
99
|
+
required: ["user_id"],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
106
|
+
const { token, baseUrl = DEFAULT_BASE_URL, accountId } = loadAccount();
|
|
107
|
+
const { name, arguments: args } = req.params;
|
|
108
|
+
try {
|
|
109
|
+
let result: unknown;
|
|
110
|
+
if (name === "weixin_send") {
|
|
111
|
+
const a = (args ?? {}) as { to?: string; text?: string; context_token?: string };
|
|
112
|
+
result = await sendTextMessage(assertStr(a.to, "to"), assertStr(a.text, "text"), token!, baseUrl, a.context_token);
|
|
113
|
+
} else if (name === "weixin_poll") {
|
|
114
|
+
const { reset_cursor } = (args ?? {}) as { reset_cursor?: boolean };
|
|
115
|
+
const cursor = reset_cursor ? "" : loadCursor(accountId);
|
|
116
|
+
const resp = await getUpdates(token!, baseUrl, cursor);
|
|
117
|
+
if (resp.get_updates_buf) saveCursor(accountId, resp.get_updates_buf);
|
|
118
|
+
result = resp;
|
|
119
|
+
} else if (name === "weixin_get_config") {
|
|
120
|
+
const a = (args ?? {}) as { user_id?: string; context_token?: string };
|
|
121
|
+
result = await getConfig(assertStr(a.user_id, "user_id"), token!, baseUrl, a.context_token);
|
|
122
|
+
} else {
|
|
123
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
124
|
+
}
|
|
125
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return { content: [{ type: "text", text: `Error: ${fmtErr(err)}` }], isError: true };
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return server;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Express HTTP server ────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
const app = express();
|
|
137
|
+
app.use(express.json());
|
|
138
|
+
|
|
139
|
+
// Session store for stateful transports
|
|
140
|
+
const sessions = new Map<string, StreamableHTTPServerTransport>();
|
|
141
|
+
|
|
142
|
+
app.post("/mcp", async (req, res) => {
|
|
143
|
+
// Check if this is an existing session
|
|
144
|
+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
145
|
+
let transport = sessionId ? sessions.get(sessionId) : undefined;
|
|
146
|
+
|
|
147
|
+
if (!transport) {
|
|
148
|
+
// New session
|
|
149
|
+
const newSessionId = randomUUID();
|
|
150
|
+
transport = new StreamableHTTPServerTransport({
|
|
151
|
+
sessionIdGenerator: () => newSessionId,
|
|
152
|
+
onsessioninitialized: (id) => { sessions.set(id, transport!); },
|
|
153
|
+
});
|
|
154
|
+
transport.onclose = () => { sessions.delete(newSessionId); };
|
|
155
|
+
const server = createMCPServer();
|
|
156
|
+
await server.connect(transport);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await transport.handleRequest(req, res, req.body);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
app.get("/mcp", async (req, res) => {
|
|
163
|
+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
164
|
+
const transport = sessionId ? sessions.get(sessionId) : undefined;
|
|
165
|
+
if (!transport) { res.status(404).json({ error: "Session not found" }); return; }
|
|
166
|
+
await transport.handleRequest(req, res);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
app.delete("/mcp", async (req, res) => {
|
|
170
|
+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
171
|
+
const transport = sessionId ? sessions.get(sessionId) : undefined;
|
|
172
|
+
if (!transport) { res.status(404).json({ error: "Session not found" }); return; }
|
|
173
|
+
await transport.handleRequest(req, res);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
app.get("/health", (_req, res) => {
|
|
177
|
+
res.json({ status: "ok", port, sessions: sessions.size });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
app.listen(port, () => {
|
|
181
|
+
console.log(`[weixin-mcp] HTTP MCP server listening on port ${port}`);
|
|
182
|
+
console.log(`[weixin-mcp] MCP endpoint: http://localhost:${port}/mcp`);
|
|
183
|
+
});
|
package/src/status.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
const STATE_DIR =
|
|
6
|
-
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
7
|
-
path.join(os.homedir(), ".openclaw");
|
|
8
|
-
|
|
9
|
-
const ACCOUNTS_DIR = path.join(STATE_DIR, "openclaw-weixin", "accounts");
|
|
3
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
4
|
+
import { daemonStatus } from "./daemon.js";
|
|
10
5
|
|
|
11
6
|
export async function showStatus() {
|
|
12
7
|
console.log("🔍 weixin-mcp status\n");
|
|
13
8
|
|
|
9
|
+
// Daemon status
|
|
10
|
+
const { running, info } = daemonStatus();
|
|
11
|
+
if (running && info) {
|
|
12
|
+
console.log(`🟢 Daemon: running (pid ${info.pid}, port ${info.port})`);
|
|
13
|
+
console.log(` URL: http://localhost:${info.port}/mcp`);
|
|
14
|
+
console.log(` Started: ${new Date(info.startedAt).toLocaleString()}`);
|
|
15
|
+
} else {
|
|
16
|
+
console.log("⚫ Daemon: not running");
|
|
17
|
+
console.log(" Tip: npx weixin-mcp start");
|
|
18
|
+
}
|
|
19
|
+
console.log();
|
|
20
|
+
|
|
14
21
|
let files: string[];
|
|
15
22
|
try {
|
|
16
23
|
files = fs.readdirSync(ACCOUNTS_DIR).filter(
|