weixin-mcp 1.2.2 → 1.3.1
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/accounts.d.ts +8 -0
- package/dist/accounts.js +132 -0
- package/dist/cli.d.ts +9 -5
- package/dist/cli.js +52 -12
- package/dist/daemon.d.ts +20 -0
- package/dist/daemon.js +122 -0
- package/dist/login.js +2 -1
- package/dist/server-http.d.ts +7 -0
- package/dist/server-http.js +156 -0
- package/dist/status.js +13 -0
- package/package.json +4 -2
- package/src/accounts.ts +132 -0
- package/src/cli.ts +55 -12
- package/src/daemon.ts +136 -0
- package/src/login.ts +2 -1
- package/src/server-http.ts +183 -0
- package/src/status.ts +13 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account management commands:
|
|
3
|
+
* npx weixin-mcp accounts list — list all accounts (default)
|
|
4
|
+
* npx weixin-mcp accounts remove <id> — remove a specific account
|
|
5
|
+
* npx weixin-mcp accounts clean — remove duplicate accounts (same userId), keep newest
|
|
6
|
+
* npx weixin-mcp accounts use <id> — print export WEIXIN_ACCOUNT_ID=<id>
|
|
7
|
+
*/
|
|
8
|
+
export declare function manageAccounts(args: string[]): Promise<void>;
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account management commands:
|
|
3
|
+
* npx weixin-mcp accounts list — list all accounts (default)
|
|
4
|
+
* npx weixin-mcp accounts remove <id> — remove a specific account
|
|
5
|
+
* npx weixin-mcp accounts clean — remove duplicate accounts (same userId), keep newest
|
|
6
|
+
* npx weixin-mcp accounts use <id> — print export WEIXIN_ACCOUNT_ID=<id>
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
11
|
+
function listFiles() {
|
|
12
|
+
try {
|
|
13
|
+
return fs
|
|
14
|
+
.readdirSync(ACCOUNTS_DIR)
|
|
15
|
+
.filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json") && !f.endsWith(".cursor.json"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function loadAccount(file) {
|
|
22
|
+
const accountId = file.replace(".json", "");
|
|
23
|
+
const data = JSON.parse(fs.readFileSync(path.join(ACCOUNTS_DIR, file), "utf-8"));
|
|
24
|
+
return { ...data, accountId };
|
|
25
|
+
}
|
|
26
|
+
function removeAccount(accountId) {
|
|
27
|
+
const filePath = path.join(ACCOUNTS_DIR, `${accountId}.json`);
|
|
28
|
+
const cursorPath = path.join(ACCOUNTS_DIR, `${accountId}.cursor.json`);
|
|
29
|
+
let removed = false;
|
|
30
|
+
if (fs.existsSync(filePath)) {
|
|
31
|
+
fs.unlinkSync(filePath);
|
|
32
|
+
removed = true;
|
|
33
|
+
}
|
|
34
|
+
if (fs.existsSync(cursorPath)) {
|
|
35
|
+
fs.unlinkSync(cursorPath);
|
|
36
|
+
}
|
|
37
|
+
return removed;
|
|
38
|
+
}
|
|
39
|
+
export async function manageAccounts(args) {
|
|
40
|
+
const subcommand = args[0] ?? "list";
|
|
41
|
+
if (subcommand === "list") {
|
|
42
|
+
const files = listFiles();
|
|
43
|
+
if (files.length === 0) {
|
|
44
|
+
console.log("No accounts found. Run: npx weixin-mcp login");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const active = process.env.WEIXIN_ACCOUNT_ID ?? files[0].replace(".json", "");
|
|
48
|
+
console.log(`Accounts (${files.length}):\n`);
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
const acc = loadAccount(file);
|
|
51
|
+
const isActive = acc.accountId === active;
|
|
52
|
+
const savedAt = acc.savedAt ? new Date(acc.savedAt).toLocaleString() : "unknown";
|
|
53
|
+
console.log(`${isActive ? "● " : " "}${acc.accountId}`);
|
|
54
|
+
const displayId = acc.userId?.replace(/@im\.wechat(@im\.wechat)+$/, "@im.wechat") ?? "(unknown)";
|
|
55
|
+
console.log(` User: ${displayId}`);
|
|
56
|
+
console.log(` Saved: ${savedAt}`);
|
|
57
|
+
}
|
|
58
|
+
if (files.length > 1) {
|
|
59
|
+
console.log(`\nActive: ${active} (set WEIXIN_ACCOUNT_ID to change)`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else if (subcommand === "remove") {
|
|
63
|
+
const accountId = args[1];
|
|
64
|
+
if (!accountId) {
|
|
65
|
+
console.error("Usage: accounts remove <accountId>");
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
if (removeAccount(accountId)) {
|
|
69
|
+
console.log(`✅ Removed: ${accountId}`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
console.error(`❌ Account not found: ${accountId}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (subcommand === "clean") {
|
|
77
|
+
const files = listFiles();
|
|
78
|
+
const byUserId = new Map();
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
const acc = loadAccount(file);
|
|
81
|
+
// Normalize userId: strip duplicate @im.wechat suffix
|
|
82
|
+
const rawId = acc.userId ?? acc.accountId;
|
|
83
|
+
const key = rawId.replace(/@im\.wechat(@im\.wechat)+$/, "@im.wechat");
|
|
84
|
+
if (!byUserId.has(key))
|
|
85
|
+
byUserId.set(key, []);
|
|
86
|
+
byUserId.get(key).push(acc);
|
|
87
|
+
}
|
|
88
|
+
let removed = 0;
|
|
89
|
+
for (const [userId, accounts] of byUserId) {
|
|
90
|
+
if (accounts.length <= 1)
|
|
91
|
+
continue;
|
|
92
|
+
// Sort by savedAt desc — keep newest
|
|
93
|
+
accounts.sort((a, b) => {
|
|
94
|
+
const ta = a.savedAt ? new Date(a.savedAt).getTime() : 0;
|
|
95
|
+
const tb = b.savedAt ? new Date(b.savedAt).getTime() : 0;
|
|
96
|
+
return tb - ta;
|
|
97
|
+
});
|
|
98
|
+
const [keep, ...remove] = accounts;
|
|
99
|
+
console.log(`UserId: ${userId}`);
|
|
100
|
+
console.log(` ✓ Keep: ${keep.accountId} (${keep.savedAt ?? "?"})`);
|
|
101
|
+
for (const acc of remove) {
|
|
102
|
+
removeAccount(acc.accountId);
|
|
103
|
+
console.log(` ✗ Removed: ${acc.accountId} (${acc.savedAt ?? "?"})`);
|
|
104
|
+
removed++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (removed === 0) {
|
|
108
|
+
console.log("✅ No duplicates found.");
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
console.log(`\n✅ Cleaned ${removed} duplicate account(s).`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (subcommand === "use") {
|
|
115
|
+
const accountId = args[1];
|
|
116
|
+
if (!accountId) {
|
|
117
|
+
console.error("Usage: accounts use <accountId>");
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
const filePath = path.join(ACCOUNTS_DIR, `${accountId}.json`);
|
|
121
|
+
if (!fs.existsSync(filePath)) {
|
|
122
|
+
console.error(`❌ Account not found: ${accountId}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
console.log(`export WEIXIN_ACCOUNT_ID="${accountId}"`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.error(`Unknown accounts subcommand: ${subcommand}`);
|
|
129
|
+
console.error("Usage: accounts [list|remove <id>|clean|use <id>]");
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
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,53 @@ 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 === "accounts") {
|
|
45
|
+
const { manageAccounts } = await import("./accounts.js");
|
|
46
|
+
await manageAccounts(process.argv.slice(3)); // [subcommand, ...args]
|
|
47
|
+
}
|
|
48
|
+
else if (command === undefined || command === "serve") {
|
|
49
|
+
// Default: stdio MCP server (for Claude Desktop integration)
|
|
22
50
|
await import("./index.js");
|
|
23
51
|
}
|
|
24
52
|
else {
|
|
25
53
|
console.error(`Unknown command: ${command}`);
|
|
26
|
-
console.error(`
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
54
|
+
console.error(`
|
|
55
|
+
Usage: npx weixin-mcp [command]
|
|
56
|
+
|
|
57
|
+
Commands:
|
|
58
|
+
(no args) Start stdio MCP server (Claude Desktop mode)
|
|
59
|
+
login QR code login
|
|
60
|
+
status Show account and daemon status
|
|
61
|
+
start [--port n] Start HTTP MCP daemon in background (default: 3001)
|
|
62
|
+
stop Stop daemon
|
|
63
|
+
restart Restart daemon
|
|
64
|
+
logs [-f] Show daemon logs (-f to follow)
|
|
65
|
+
accounts [list] List all accounts
|
|
66
|
+
accounts remove <id> Remove an account
|
|
67
|
+
accounts clean Remove duplicate accounts (same userId), keep newest
|
|
68
|
+
accounts use <id> Print export command for switching accounts
|
|
69
|
+
`);
|
|
30
70
|
process.exit(1);
|
|
31
71
|
}
|
|
32
72
|
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/login.js
CHANGED
|
@@ -76,7 +76,8 @@ export async function main() {
|
|
|
76
76
|
// Use bot_id or a random ID as account identifier
|
|
77
77
|
const accountId = status.ilink_bot_id?.replace("@", "-").replace(".", "-")
|
|
78
78
|
?? crypto.randomBytes(6).toString("hex") + "-im-bot";
|
|
79
|
-
|
|
79
|
+
// userId from API already contains @im.wechat suffix — don't append again
|
|
80
|
+
saveAccount(accountId, token, baseUrl, userId ?? undefined);
|
|
80
81
|
console.log(`\n🎉 Logged in! Account: ${accountId}`);
|
|
81
82
|
console.log(` UserId: ${userId ?? "(unknown)"}`);
|
|
82
83
|
console.log("\nYou can now start the MCP server:");
|
|
@@ -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,8 +1,21 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ACCOUNTS_DIR } from "./paths.js";
|
|
4
|
+
import { daemonStatus } from "./daemon.js";
|
|
4
5
|
export async function showStatus() {
|
|
5
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();
|
|
6
19
|
let files;
|
|
7
20
|
try {
|
|
8
21
|
files = fs.readdirSync(ACCOUNTS_DIR).filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json") && !f.endsWith(".cursor.json"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weixin-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
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/accounts.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account management commands:
|
|
3
|
+
* npx weixin-mcp accounts list — list all accounts (default)
|
|
4
|
+
* npx weixin-mcp accounts remove <id> — remove a specific account
|
|
5
|
+
* npx weixin-mcp accounts clean — remove duplicate accounts (same userId), keep newest
|
|
6
|
+
* npx weixin-mcp accounts use <id> — print export WEIXIN_ACCOUNT_ID=<id>
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { ACCOUNTS_DIR } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
interface AccountData {
|
|
14
|
+
token?: string;
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
userId?: string;
|
|
17
|
+
savedAt?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function listFiles(): string[] {
|
|
21
|
+
try {
|
|
22
|
+
return fs
|
|
23
|
+
.readdirSync(ACCOUNTS_DIR)
|
|
24
|
+
.filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json") && !f.endsWith(".cursor.json"));
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadAccount(file: string): AccountData & { accountId: string } {
|
|
31
|
+
const accountId = file.replace(".json", "");
|
|
32
|
+
const data = JSON.parse(fs.readFileSync(path.join(ACCOUNTS_DIR, file), "utf-8")) as AccountData;
|
|
33
|
+
return { ...data, accountId };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function removeAccount(accountId: string) {
|
|
37
|
+
const filePath = path.join(ACCOUNTS_DIR, `${accountId}.json`);
|
|
38
|
+
const cursorPath = path.join(ACCOUNTS_DIR, `${accountId}.cursor.json`);
|
|
39
|
+
let removed = false;
|
|
40
|
+
if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); removed = true; }
|
|
41
|
+
if (fs.existsSync(cursorPath)) { fs.unlinkSync(cursorPath); }
|
|
42
|
+
return removed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function manageAccounts(args: string[]) {
|
|
46
|
+
const subcommand = args[0] ?? "list";
|
|
47
|
+
|
|
48
|
+
if (subcommand === "list") {
|
|
49
|
+
const files = listFiles();
|
|
50
|
+
if (files.length === 0) {
|
|
51
|
+
console.log("No accounts found. Run: npx weixin-mcp login");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const active = process.env.WEIXIN_ACCOUNT_ID ?? files[0].replace(".json", "");
|
|
55
|
+
console.log(`Accounts (${files.length}):\n`);
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
const acc = loadAccount(file);
|
|
58
|
+
const isActive = acc.accountId === active;
|
|
59
|
+
const savedAt = acc.savedAt ? new Date(acc.savedAt).toLocaleString() : "unknown";
|
|
60
|
+
console.log(`${isActive ? "● " : " "}${acc.accountId}`);
|
|
61
|
+
const displayId = acc.userId?.replace(/@im\.wechat(@im\.wechat)+$/, "@im.wechat") ?? "(unknown)";
|
|
62
|
+
console.log(` User: ${displayId}`);
|
|
63
|
+
console.log(` Saved: ${savedAt}`);
|
|
64
|
+
}
|
|
65
|
+
if (files.length > 1) {
|
|
66
|
+
console.log(`\nActive: ${active} (set WEIXIN_ACCOUNT_ID to change)`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
} else if (subcommand === "remove") {
|
|
70
|
+
const accountId = args[1];
|
|
71
|
+
if (!accountId) { console.error("Usage: accounts remove <accountId>"); process.exit(1); }
|
|
72
|
+
if (removeAccount(accountId)) {
|
|
73
|
+
console.log(`✅ Removed: ${accountId}`);
|
|
74
|
+
} else {
|
|
75
|
+
console.error(`❌ Account not found: ${accountId}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
} else if (subcommand === "clean") {
|
|
80
|
+
const files = listFiles();
|
|
81
|
+
const byUserId = new Map<string, Array<AccountData & { accountId: string }>>();
|
|
82
|
+
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
const acc = loadAccount(file);
|
|
85
|
+
// Normalize userId: strip duplicate @im.wechat suffix
|
|
86
|
+
const rawId = acc.userId ?? acc.accountId;
|
|
87
|
+
const key = rawId.replace(/@im\.wechat(@im\.wechat)+$/, "@im.wechat");
|
|
88
|
+
if (!byUserId.has(key)) byUserId.set(key, []);
|
|
89
|
+
byUserId.get(key)!.push(acc);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let removed = 0;
|
|
93
|
+
for (const [userId, accounts] of byUserId) {
|
|
94
|
+
if (accounts.length <= 1) continue;
|
|
95
|
+
// Sort by savedAt desc — keep newest
|
|
96
|
+
accounts.sort((a, b) => {
|
|
97
|
+
const ta = a.savedAt ? new Date(a.savedAt).getTime() : 0;
|
|
98
|
+
const tb = b.savedAt ? new Date(b.savedAt).getTime() : 0;
|
|
99
|
+
return tb - ta;
|
|
100
|
+
});
|
|
101
|
+
const [keep, ...remove] = accounts;
|
|
102
|
+
console.log(`UserId: ${userId}`);
|
|
103
|
+
console.log(` ✓ Keep: ${keep.accountId} (${keep.savedAt ?? "?"})`);
|
|
104
|
+
for (const acc of remove) {
|
|
105
|
+
removeAccount(acc.accountId);
|
|
106
|
+
console.log(` ✗ Removed: ${acc.accountId} (${acc.savedAt ?? "?"})`);
|
|
107
|
+
removed++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (removed === 0) {
|
|
112
|
+
console.log("✅ No duplicates found.");
|
|
113
|
+
} else {
|
|
114
|
+
console.log(`\n✅ Cleaned ${removed} duplicate account(s).`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
} else if (subcommand === "use") {
|
|
118
|
+
const accountId = args[1];
|
|
119
|
+
if (!accountId) { console.error("Usage: accounts use <accountId>"); process.exit(1); }
|
|
120
|
+
const filePath = path.join(ACCOUNTS_DIR, `${accountId}.json`);
|
|
121
|
+
if (!fs.existsSync(filePath)) {
|
|
122
|
+
console.error(`❌ Account not found: ${accountId}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
console.log(`export WEIXIN_ACCOUNT_ID="${accountId}"`);
|
|
126
|
+
|
|
127
|
+
} else {
|
|
128
|
+
console.error(`Unknown accounts subcommand: ${subcommand}`);
|
|
129
|
+
console.error("Usage: accounts [list|remove <id>|clean|use <id>]");
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1,30 +1,73 @@
|
|
|
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 === "accounts") {
|
|
47
|
+
const { manageAccounts } = await import("./accounts.js");
|
|
48
|
+
await manageAccounts(process.argv.slice(3)); // [subcommand, ...args]
|
|
49
|
+
|
|
50
|
+
} else if (command === undefined || command === "serve") {
|
|
51
|
+
// Default: stdio MCP server (for Claude Desktop integration)
|
|
22
52
|
await import("./index.js");
|
|
53
|
+
|
|
23
54
|
} else {
|
|
24
55
|
console.error(`Unknown command: ${command}`);
|
|
25
|
-
console.error(`
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
56
|
+
console.error(`
|
|
57
|
+
Usage: npx weixin-mcp [command]
|
|
58
|
+
|
|
59
|
+
Commands:
|
|
60
|
+
(no args) Start stdio MCP server (Claude Desktop mode)
|
|
61
|
+
login QR code login
|
|
62
|
+
status Show account and daemon status
|
|
63
|
+
start [--port n] Start HTTP MCP daemon in background (default: 3001)
|
|
64
|
+
stop Stop daemon
|
|
65
|
+
restart Restart daemon
|
|
66
|
+
logs [-f] Show daemon logs (-f to follow)
|
|
67
|
+
accounts [list] List all accounts
|
|
68
|
+
accounts remove <id> Remove an account
|
|
69
|
+
accounts clean Remove duplicate accounts (same userId), keep newest
|
|
70
|
+
accounts use <id> Print export command for switching accounts
|
|
71
|
+
`);
|
|
29
72
|
process.exit(1);
|
|
30
73
|
}
|
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/login.ts
CHANGED
|
@@ -104,7 +104,8 @@ export async function main() {
|
|
|
104
104
|
// Use bot_id or a random ID as account identifier
|
|
105
105
|
const accountId = status.ilink_bot_id?.replace("@", "-").replace(".", "-")
|
|
106
106
|
?? crypto.randomBytes(6).toString("hex") + "-im-bot";
|
|
107
|
-
|
|
107
|
+
// userId from API already contains @im.wechat suffix — don't append again
|
|
108
|
+
saveAccount(accountId, token, baseUrl, userId ?? undefined);
|
|
108
109
|
console.log(`\n🎉 Logged in! Account: ${accountId}`);
|
|
109
110
|
console.log(` UserId: ${userId ?? "(unknown)"}`);
|
|
110
111
|
console.log("\nYou can now start the MCP server:");
|
|
@@ -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,10 +1,23 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ACCOUNTS_DIR } from "./paths.js";
|
|
4
|
+
import { daemonStatus } from "./daemon.js";
|
|
4
5
|
|
|
5
6
|
export async function showStatus() {
|
|
6
7
|
console.log("🔍 weixin-mcp status\n");
|
|
7
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
|
+
|
|
8
21
|
let files: string[];
|
|
9
22
|
try {
|
|
10
23
|
files = fs.readdirSync(ACCOUNTS_DIR).filter(
|