dingtalk-ask-mcp-server 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,12 +138,17 @@ Codex 若 `approval_policy="never"` 本就不弹命令审批,故只提供 `ask_h
138
138
 
139
139
  ```bash
140
140
  dingtalk-ask install … # 一键接入(见上)
141
+ dingtalk-ask info # 查看配置与进程状态(守护进程/离开模式/各宿主是否配好)
141
142
  dingtalk-ask daemon status|stop # 查看/停止共享守护进程
142
143
  dingtalk-ask away on|off|status # 本地切换离开模式
143
- dingtalk-ask-mcp-server --client-id … … # 服务本体(一般由宿主拉起,极少手动跑)
144
+ dingtalk-ask clean # 清空运行时状态(守护进程/锁/flag/就绪标记/日志),不动配置文件
145
+ dingtalk-ask -v | -h # 版本 / 帮助
146
+ dingtalk-ask-mcp-server --client-id … … # 服务本体(一般由宿主拉起,极少手动跑;支持 -v/-h)
144
147
  dingtalk-ask-install-hook [--global|<path>] # 只装 CC 命令审批钩子
145
148
  ```
146
149
 
150
+ 首次连上钉钉时,会给你单聊发一条**「已连接就绪(附 robotCode)」**确认,便于核对连的是哪个机器人;每个机器人只发一次。
151
+
147
152
  守护进程自动拉起、闲置自动退出;`DINGTALK_DAEMON_IDLE_MS` 可调(`0` 禁用)。诊断:守护进程日志在系统临时目录 `dingtalk-daemon.log`;设 `DINGTALK_DEBUG=1` 打印钉钉每帧。
148
153
 
149
154
  ---
package/dist/daemon.js CHANGED
@@ -15,6 +15,8 @@ import { DingtalkClient } from './dingtalk/client.js';
15
15
  import { DecisionRegistry } from './decision/registry.js';
16
16
  import { askWithTimeout } from './decision/coordinator.js';
17
17
  import { startEndpoint } from './daemon/endpoint.js';
18
+ import { buildTextMessage } from './dingtalk/cardPayload.js';
19
+ import { hasGreeted, markGreeted, buildGreeting } from './greeting.js';
18
20
  import { daemonLockPath, writeDaemonLockExclusive, readDaemonLock, removeDaemonLock, } from './daemon/lock.js';
19
21
  import { probeHealth } from './daemonClient.js';
20
22
  /** 选举重试次数(应对陈旧锁的回收竞争)。 */
@@ -136,6 +138,13 @@ async function main() {
136
138
  registry = new DecisionRegistry(client);
137
139
  ready = true;
138
140
  registerCleanup(endpoint, client, lockPath);
141
+ // 首次连接成功时给钉钉发一条就绪提示(每个 robotCode 只发一次),便于确认连的是哪个机器人。
142
+ if (client.isConnected() && !hasGreeted(config.robotCode)) {
143
+ client
144
+ .sendOto(buildTextMessage(buildGreeting(config.robotCode)))
145
+ .then(() => markGreeted(config.robotCode))
146
+ .catch((error) => console.error(`就绪提示发送失败:${error instanceof Error ? error.message : String(error)}`));
147
+ }
139
148
  // 空闲自关:无待答且超过阈值无 /ask 活动则退出删锁(下次 MCP server 再拉起)。
140
149
  const idleMs = resolveIdleMs(process.env.DINGTALK_DAEMON_IDLE_MS);
141
150
  if (idleMs > 0) {
@@ -209,6 +209,14 @@ export class DingtalkClient {
209
209
  throw new Error(`获取钉钉 accessToken 失败:${describeAxiosError(error)}`);
210
210
  }
211
211
  }
212
+ /**
213
+ * 当前是否已成功建立 Stream 长连(用于首次连接就绪提示等)。
214
+ *
215
+ * @returns 已连上 → true;未建连或握手未成功 → false。
216
+ */
217
+ isConnected() {
218
+ return this.dwClient?.connected === true;
219
+ }
212
220
  /**
213
221
  * 断开 Stream 长连(若已建连)。进程退出前调用,释放 socket。
214
222
  *
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 首次连接就绪提示:守护进程成功连上钉钉后,给用户单聊发一条"已连接(哪个机器人)"确认,
3
+ * 便于确认凭证对不对、连的是哪个 bot、接收链路通不通。
4
+ *
5
+ * 每个 robotCode 只发一次(tmpdir 标记文件),避免每次开 CC / 重启守护进程都刷屏。
6
+ * 纯逻辑(路径/文案)与极薄 IO 分离,便于单测。
7
+ */
8
+ import { existsSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ /**
12
+ * 就绪提示标记文件路径(按 robotCode 区分;非法字符替换为下划线)。
13
+ *
14
+ * @param robotCode 机器人 robotCode。
15
+ * @returns tmpdir 下的标记文件绝对路径。
16
+ */
17
+ export function greetingMarkerPath(robotCode) {
18
+ const safe = robotCode.replace(/[^a-zA-Z0-9_-]/g, '_');
19
+ return join(tmpdir(), `dingtalk-greeted-${safe}.flag`);
20
+ }
21
+ /**
22
+ * 是否已对该 robotCode 发过就绪提示。
23
+ *
24
+ * @param robotCode 机器人 robotCode。
25
+ * @param path 标记路径;缺省用 greetingMarkerPath(测试注入隔离路径)。
26
+ * @returns 已发过 → true。
27
+ */
28
+ export function hasGreeted(robotCode, path = greetingMarkerPath(robotCode)) {
29
+ try {
30
+ return existsSync(path);
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ /**
37
+ * 记录已发过就绪提示(写标记文件,内容为 ISO 时间戳)。
38
+ *
39
+ * @param robotCode 机器人 robotCode。
40
+ * @param path 标记路径;缺省用 greetingMarkerPath(测试注入隔离路径)。
41
+ */
42
+ export function markGreeted(robotCode, path = greetingMarkerPath(robotCode)) {
43
+ try {
44
+ writeFileSync(path, new Date().toISOString(), 'utf8');
45
+ }
46
+ catch {
47
+ /* 写标记失败不影响主流程(最多下次重复发一次)。 */
48
+ }
49
+ }
50
+ /**
51
+ * 构造就绪提示文案(纯函数,可测)。
52
+ *
53
+ * @param robotCode 机器人 robotCode(帮用户确认连的是哪个 bot)。
54
+ * @returns 提示正文。
55
+ */
56
+ export function buildGreeting(robotCode) {
57
+ return (`✅ dingtalk-ask 已连接就绪,可以在这里接收决策与命令审批。\n` +
58
+ `机器人 robotCode:${robotCode}\n` +
59
+ `离开电脑时发 /away 开启推送,回来发 /back 关闭。`);
60
+ }
package/dist/index.js CHANGED
@@ -17,10 +17,29 @@ import { resolveDingtalkConfig } from "./dingtalk/config.js";
17
17
  import { isAway, setAway } from "./awayState.js";
18
18
  import { resolveAgentLabel } from "./agentLabel.js";
19
19
  import { mergeArgsIntoEnv } from "./cliArgs.js";
20
+ import { packageVersion } from "./version.js";
20
21
  import { ensureDaemon, postAskToDaemon } from "./daemonClient.js";
21
22
  import { daemonLockPath } from "./daemon/lock.js";
23
+ // -v/--version、-h/--help:在读配置前拦截,免凭证也能用。
24
+ const rawArgs = process.argv.slice(2);
25
+ if (rawArgs.includes("-v") || rawArgs.includes("--version")) {
26
+ console.log(packageVersion());
27
+ process.exit(0);
28
+ }
29
+ if (rawArgs.includes("-h") || rawArgs.includes("--help")) {
30
+ console.log(`dingtalk-ask-mcp-server v${packageVersion()} —— 钉钉 human-in-the-loop MCP server`);
31
+ console.log("");
32
+ console.log("一般由宿主(Claude Code / Codex)按 MCP 配置拉起,也可手动启动:");
33
+ console.log(" dingtalk-ask-mcp-server --client-id <AppKey> --client-secret <AppSecret> \\");
34
+ console.log(" --robot-code <robotCode> --user-id <userId> \\");
35
+ console.log(" [--timeout <ms>] [--agent-label <名字>]");
36
+ console.log("");
37
+ console.log("一键接入 / 管理请用 `dingtalk-ask`(install / daemon / away / clean)。");
38
+ console.log("凭证也可用 DINGTALK_CLIENT_ID 等环境变量提供。");
39
+ process.exit(0);
40
+ }
22
41
  /** 合并 CLI 参数与环境变量(参数优先):允许用 --client-id 等命令行参数传钉钉配置。 */
23
- const env = mergeArgsIntoEnv(process.env, process.argv.slice(2));
42
+ const env = mergeArgsIntoEnv(process.env, rawArgs);
24
43
  /**
25
44
  * 解析钉钉配置;缺凭证时打印缺失项到 stderr 并 exit(1)(AC5:不产生半可用状态)。
26
45
  *
@@ -10,17 +10,18 @@
10
10
  * 写入均为宿主本地配置(.mcp.json / .claude/settings.json / ~/.codex/config.toml,皆不入库),
11
11
  * 合并保留既有内容、幂等。纯逻辑在 installConfig.ts / installHookCore.ts。
12
12
  */
13
- import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from 'node:fs';
14
- import { homedir } from 'node:os';
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, unlinkSync, readdirSync, statSync } from 'node:fs';
14
+ import { homedir, tmpdir } from 'node:os';
15
15
  import { join, dirname } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { resolveDingtalkConfig } from './dingtalk/config.js';
18
18
  import { mergeArgsIntoEnv } from './cliArgs.js';
19
+ import { packageVersion } from './version.js';
19
20
  import { mergeHook, permissionHookPath, resolveSettingsPath } from './installHookCore.js';
20
21
  import { daemonLockPath, readDaemonLock, removeDaemonLock } from './daemon/lock.js';
21
22
  import { probeHealth } from './daemonClient.js';
22
- import { isAway, setAway } from './awayState.js';
23
- import { buildMcpEntry, mergeMcpServers, ensureMcpToolTimeout, codexHasDingtalk, buildCodexBlock, MCP_SERVER_NAME, } from './installConfig.js';
23
+ import { isAway, setAway, awayFlagPath } from './awayState.js';
24
+ import { buildMcpEntry, mergeMcpServers, ensureMcpToolTimeout, codexHasDingtalk, buildCodexBlock, inspectMcpJson, inspectSettingsHook, MCP_SERVER_NAME, } from './installConfig.js';
24
25
  /** 解析目标宿主。默认 CC;Codex 见 resolveCodexTarget。 */
25
26
  function parseHost(argv) {
26
27
  const i = argv.indexOf('--host');
@@ -93,15 +94,183 @@ function installCodex(config, nodePath, indexPath) {
93
94
  console.log(`✅ [Codex] 追加 [mcp_servers.dingtalk-ask] 到 ${cfgPath}${content !== '' ? '(已备份 .bak)' : ''}`);
94
95
  }
95
96
  function usage() {
96
- console.log('dingtalk-ask —— 钉钉 human-in-the-loop MCP 工具');
97
+ console.log(`dingtalk-ask v${packageVersion()} —— 钉钉 human-in-the-loop MCP 工具`);
97
98
  console.log('');
98
99
  console.log('用法:');
99
100
  console.log(' dingtalk-ask install --client-id … --client-secret … --robot-code … --user-id … \\');
100
101
  console.log(' [--timeout <ms>] [--host cc|codex|both] # 一键装 MCP+钩子');
101
102
  console.log(' dingtalk-ask daemon <status|stop> # 查看/停止共享守护进程');
102
103
  console.log(' dingtalk-ask away <on|off|status> # 本地切换离开模式');
104
+ console.log(' dingtalk-ask info # 查看配置与进程状态');
105
+ console.log(' dingtalk-ask clean # 清空运行时状态(守护进程/锁/flag/标记/日志)');
106
+ console.log(' dingtalk-ask -v | --version # 版本');
107
+ console.log(' dingtalk-ask -h | --help # 帮助');
103
108
  process.exit(1);
104
109
  }
110
+ /** 宽松读 JSON:文件缺失/损坏都返回空对象(info 用,不抛错)。 */
111
+ function tolerantJson(path) {
112
+ try {
113
+ return JSON.parse(readFileSync(path, 'utf8'));
114
+ }
115
+ catch {
116
+ return {};
117
+ }
118
+ }
119
+ /** 轻度掩码(展示 userId 等,不完全暴露)。 */
120
+ function mask(v) {
121
+ if (v === undefined || v === '') {
122
+ return '(未知)';
123
+ }
124
+ return v.length <= 4 ? '***' : `${v.slice(0, 3)}***${v.slice(-2)}`;
125
+ }
126
+ /** info 子命令:一屏看配置与进程状态。 */
127
+ async function runInfo() {
128
+ const tmp = tmpdir();
129
+ const cwd = process.cwd();
130
+ console.log(`dingtalk-ask v${packageVersion()}`);
131
+ console.log('\n【进程】');
132
+ const lock = readDaemonLock(daemonLockPath());
133
+ if (lock === undefined) {
134
+ console.log(' 守护进程:未运行');
135
+ }
136
+ else {
137
+ const probe = await probeHealth(lock);
138
+ console.log(` 守护进程:${probe.ok ? '在跑' : '锁存在但无应答'} port=${lock.port} pid=${lock.pid} 就绪=${probe.ready}`);
139
+ }
140
+ console.log(` 离开模式:${isAway() ? '开(推钉钉)' : '关(只在终端)'}`);
141
+ let greeted = [];
142
+ try {
143
+ greeted = readdirSync(tmp)
144
+ .filter((f) => f.startsWith('dingtalk-greeted-'))
145
+ .map((f) => f.replace(/^dingtalk-greeted-/, '').replace(/\.flag$/, ''));
146
+ }
147
+ catch {
148
+ /* 忽略 */
149
+ }
150
+ console.log(` 已发就绪提示的机器人:${greeted.length > 0 ? greeted.join('、') : '无'}`);
151
+ const logPath = join(tmp, 'dingtalk-daemon.log');
152
+ try {
153
+ const st = statSync(logPath);
154
+ console.log(` 守护进程日志:${logPath}(${Math.round(st.size / 1024)}KB)`);
155
+ }
156
+ catch {
157
+ console.log(' 守护进程日志:无');
158
+ }
159
+ console.log('\n【配置 · Claude Code】');
160
+ const mcp = inspectMcpJson(tolerantJson(join(cwd, '.mcp.json')));
161
+ console.log(` .mcp.json (${cwd}):${mcp.configured ? `✅ 已配(command=${mcp.command ?? '?'}, userId=${mask(mcp.userId)})` : '❌ 未配'}`);
162
+ const projSettings = inspectSettingsHook(tolerantJson(join(cwd, '.claude', 'settings.json')));
163
+ // MCP_TOOL_TIMEOUT 可能在 settings.json 或 settings.local.json,两处都看。
164
+ const localTimeout = inspectSettingsHook(tolerantJson(join(cwd, '.claude', 'settings.local.json'))).mcpToolTimeout;
165
+ const mcpToolTimeout = projSettings.mcpToolTimeout ?? localTimeout;
166
+ console.log(` 项目 settings.json:命令审批钩子 ${projSettings.hook ? '✅' : '❌'}` +
167
+ `,MCP_TOOL_TIMEOUT=${mcpToolTimeout ?? '未设'}`);
168
+ const userSettings = inspectSettingsHook(tolerantJson(join(homedir(), '.claude', 'settings.json')));
169
+ console.log(` 用户 settings.json (~):命令审批钩子 ${userSettings.hook ? '✅' : '❌'}`);
170
+ console.log('\n【配置 · Codex】');
171
+ const codexPath = join(homedir(), '.codex', 'config.toml');
172
+ let codexContent = '';
173
+ try {
174
+ codexContent = readFileSync(codexPath, 'utf8');
175
+ }
176
+ catch {
177
+ codexContent = '';
178
+ }
179
+ if (codexContent === '') {
180
+ console.log(` ${codexPath}:无文件(未用 Codex?)`);
181
+ }
182
+ else if (codexHasDingtalk(codexContent)) {
183
+ const m = /\[mcp_servers\.dingtalk-ask\][\s\S]*?tool_timeout_sec\s*=\s*(\d+)/.exec(codexContent);
184
+ console.log(` ${codexPath}:✅ 已配(tool_timeout_sec=${m ? m[1] : '?'})`);
185
+ }
186
+ else {
187
+ console.log(` ${codexPath}:❌ 未配 dingtalk-ask`);
188
+ }
189
+ }
190
+ function main() {
191
+ const args = process.argv.slice(2);
192
+ const cmd = args[0];
193
+ const rest = args.slice(1);
194
+ if (cmd === '-v' || cmd === '--version') {
195
+ console.log(packageVersion());
196
+ return;
197
+ }
198
+ if (cmd === '-h' || cmd === '--help') {
199
+ usage();
200
+ }
201
+ if (cmd === 'install') {
202
+ runInstall(rest);
203
+ }
204
+ else if (cmd === 'daemon') {
205
+ void runDaemon(rest);
206
+ }
207
+ else if (cmd === 'away') {
208
+ runAway(rest);
209
+ }
210
+ else if (cmd === 'info') {
211
+ void runInfo();
212
+ }
213
+ else if (cmd === 'clean') {
214
+ runClean();
215
+ }
216
+ else {
217
+ usage();
218
+ }
219
+ }
220
+ main();
221
+ function runClean() {
222
+ const tmp = tmpdir();
223
+ // 1. 停守护进程 + 删锁。
224
+ const lock = readDaemonLock(daemonLockPath());
225
+ if (lock !== undefined && lock.pid > 0) {
226
+ try {
227
+ process.kill(lock.pid, 'SIGTERM');
228
+ console.log(`· 已停守护进程 pid=${lock.pid}`);
229
+ }
230
+ catch {
231
+ /* 可能已退出 */
232
+ }
233
+ }
234
+ removeDaemonLock(daemonLockPath());
235
+ // 2. 离开模式 flag。
236
+ try {
237
+ unlinkSync(awayFlagPath());
238
+ console.log('· 已删离开模式 flag');
239
+ }
240
+ catch {
241
+ /* 不存在忽略 */
242
+ }
243
+ // 3. 就绪提示标记(各 robotCode)。
244
+ let markers = 0;
245
+ try {
246
+ for (const f of readdirSync(tmp)) {
247
+ if (f.startsWith('dingtalk-greeted-')) {
248
+ try {
249
+ unlinkSync(join(tmp, f));
250
+ markers += 1;
251
+ }
252
+ catch {
253
+ /* 忽略 */
254
+ }
255
+ }
256
+ }
257
+ }
258
+ catch {
259
+ /* 忽略 */
260
+ }
261
+ if (markers > 0) {
262
+ console.log(`· 已删 ${markers} 个就绪提示标记`);
263
+ }
264
+ // 4. 守护进程日志。
265
+ try {
266
+ unlinkSync(join(tmp, 'dingtalk-daemon.log'));
267
+ console.log('· 已删守护进程日志');
268
+ }
269
+ catch {
270
+ /* 忽略 */
271
+ }
272
+ console.log('✅ 已清空运行时状态。配置文件(.mcp.json / settings.json / config.toml)未改动。');
273
+ }
105
274
  /** install 子命令:装 MCP + 钩子。 */
106
275
  function runInstall(rest) {
107
276
  let config;
@@ -187,21 +356,3 @@ function runAway(rest) {
187
356
  }
188
357
  usage();
189
358
  }
190
- function main() {
191
- const args = process.argv.slice(2);
192
- const cmd = args[0];
193
- const rest = args.slice(1);
194
- if (cmd === 'install') {
195
- runInstall(rest);
196
- }
197
- else if (cmd === 'daemon') {
198
- void runDaemon(rest);
199
- }
200
- else if (cmd === 'away') {
201
- runAway(rest);
202
- }
203
- else {
204
- usage();
205
- }
206
- }
207
- main();
@@ -54,6 +54,32 @@ export function ensureMcpToolTimeout(settings, ms) {
54
54
  export function codexHasDingtalk(content) {
55
55
  return content.includes(`[mcp_servers.${MCP_SERVER_NAME}]`);
56
56
  }
57
+ /** 检查 .mcp.json 是否配了 dingtalk-ask,并抽取 command / userId 供展示(纯函数)。 */
58
+ export function inspectMcpJson(obj) {
59
+ const servers = obj.mcpServers;
60
+ const s = servers?.[MCP_SERVER_NAME];
61
+ if (s === undefined) {
62
+ return { configured: false };
63
+ }
64
+ let userId = s.env?.DINGTALK_USER_ID;
65
+ if (userId === undefined && Array.isArray(s.args)) {
66
+ const i = s.args.indexOf('--user-id');
67
+ if (i >= 0) {
68
+ userId = s.args[i + 1];
69
+ }
70
+ }
71
+ return { configured: true, command: s.command, userId };
72
+ }
73
+ /** 检查 settings.json 是否装了命令审批钩子、以及 MCP_TOOL_TIMEOUT(纯函数)。 */
74
+ export function inspectSettingsHook(obj) {
75
+ const hooks = obj.hooks;
76
+ const list = hooks?.PermissionRequest;
77
+ const hook = Array.isArray(list) &&
78
+ list.some((e) => Array.isArray(e.hooks) && e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('permissionHook.js')));
79
+ const env = obj.env;
80
+ const t = env?.MCP_TOOL_TIMEOUT;
81
+ return { hook, mcpToolTimeout: typeof t === 'string' ? t : undefined };
82
+ }
57
83
  /**
58
84
  * 构造 Codex config.toml 追加块(TOML 基本字符串转义,无需 TOML 库)。
59
85
  *
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 读取本包版本号(从 dist 上层的 package.json 动态读,随发版自动更新)。
3
+ */
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ /**
7
+ * 读取 package.json 的 version。
8
+ *
9
+ * @returns 版本号;读取失败时返回 'unknown'。
10
+ */
11
+ export function packageVersion() {
12
+ try {
13
+ const p = fileURLToPath(new URL('../package.json', import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(p, 'utf8'));
15
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
16
+ }
17
+ catch {
18
+ return 'unknown';
19
+ }
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dingtalk-ask-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Human-in-the-loop MCP server:把 code agent(Claude Code / Codex)的决策与命令审批推送到钉钉,阻塞等你在手机回复。离开电脑也能远程拍板。",
5
5
  "keywords": [
6
6
  "mcp",