dingtalk-ask-mcp-server 0.1.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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Claude Code PermissionRequest 钩子(瘦客户端,含安全兜底)。
3
+ *
4
+ * 构建到 dist/permissionHook.js,settings.json 用 `node <abs>/dist/permissionHook.js` 调用。
5
+ * 逻辑:读 stdin(命令) → 离开模式?→ 读守护进程锁 → POST /ask 推钉钉批/拒 →
6
+ * 映射为 PermissionRequest 决定 JSON(allow/deny),任何失败/超时/歧义 → exit 0 空输出(回落终端)。
7
+ *
8
+ * 编排核心在 permissionHookCore.ts(可注入依赖单测);本文件只做 I/O 适配与 process.exit。
9
+ *
10
+ * 只用 Node 内置 http(不用 axios,钩子要独立轻量)。安全铁律:只有明确"允许"才 allow;
11
+ * 其余一切(含超时、歧义、错误、端点不可达)都不能误放行——要么 deny、要么回落,绝不 allow。
12
+ */
13
+ import { request } from 'node:http';
14
+ import { appendFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { daemonLockPath, readDaemonLock } from './daemon/lock.js';
18
+ import { isAway } from './awayState.js';
19
+ import { defaultResolveLabel, parseAskResponse, runPermissionHook, } from './permissionHookCore.js';
20
+ /** 诊断日志开关:设 DINGTALK_HOOK_DEBUG=1 时把钩子每步写日志到 tmpdir/dingtalk-hook-debug.log(排查用)。 */
21
+ const DEBUG = process.env.DINGTALK_HOOK_DEBUG === '1';
22
+ const DEBUG_LOG = join(tmpdir(), 'dingtalk-hook-debug.log');
23
+ /** 追加一行诊断日志(仅 DEBUG 时)。 */
24
+ function dbg(msg) {
25
+ if (!DEBUG)
26
+ return;
27
+ try {
28
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`, 'utf8');
29
+ }
30
+ catch {
31
+ /* 日志失败不影响主流程 */
32
+ }
33
+ }
34
+ /**
35
+ * 钩子内部超时(毫秒):必须 < settings.json 里该钩子的 timeout(建议 settings 设 60000)。
36
+ * 到点即回落终端,不硬拦,保证用户仍能自己处理。
37
+ */
38
+ const HOOK_ASK_TIMEOUT_MS = 50_000;
39
+ /** HTTP 请求本身的兜底超时(略大于服务端决策超时,防连接吊死)。 */
40
+ const HTTP_TIMEOUT_MS = HOOK_ASK_TIMEOUT_MS + 5_000;
41
+ /**
42
+ * 回落:exit 0 且 stdout 为空 → Claude Code 正常终端弹窗(安全兜底)。
43
+ * 任何不确定的情形都走这里,绝不误放行。
44
+ */
45
+ function fallthrough() {
46
+ process.exit(0);
47
+ }
48
+ /** 读取全部 stdin。 */
49
+ function readStdin() {
50
+ return new Promise((resolve) => {
51
+ let raw = '';
52
+ process.stdin.setEncoding('utf8');
53
+ process.stdin.on('data', (c) => {
54
+ raw += c;
55
+ });
56
+ process.stdin.on('end', () => resolve(raw));
57
+ process.stdin.on('error', () => resolve(raw));
58
+ });
59
+ }
60
+ /**
61
+ * POST /ask,返回解析后的 AskResponse;任何网络/超时/解析错误 reject。
62
+ *
63
+ * @param lock 守护进程锁(端口 + token)。
64
+ * @param question 审批问题正文。
65
+ * @param label 发起该命令审批的 Agent 标签(多 Agent 场景标注来源)。
66
+ * @returns /ask 响应。
67
+ */
68
+ function postAsk(lock, question, label) {
69
+ return new Promise((resolve, reject) => {
70
+ const payload = JSON.stringify({
71
+ question,
72
+ options: ['允许', '拒绝'],
73
+ label,
74
+ timeoutMs: HOOK_ASK_TIMEOUT_MS,
75
+ });
76
+ const req = request({
77
+ host: '127.0.0.1',
78
+ port: lock.port,
79
+ path: '/ask',
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': 'application/json',
83
+ 'Content-Length': Buffer.byteLength(payload),
84
+ 'x-ask-token': lock.token,
85
+ },
86
+ timeout: HTTP_TIMEOUT_MS,
87
+ }, (res) => {
88
+ let body = '';
89
+ res.setEncoding('utf8');
90
+ res.on('data', (c) => {
91
+ body += c;
92
+ });
93
+ res.on('end', () => {
94
+ if (res.statusCode !== 200) {
95
+ reject(new Error(`/ask 返回非 200:${res.statusCode ?? '?'}`));
96
+ return;
97
+ }
98
+ try {
99
+ resolve(parseAskResponse(body));
100
+ }
101
+ catch (error) {
102
+ reject(error instanceof Error ? error : new Error(String(error)));
103
+ }
104
+ });
105
+ });
106
+ req.on('error', reject);
107
+ req.on('timeout', () => {
108
+ req.destroy(new Error('/ask 请求超时'));
109
+ });
110
+ req.write(payload);
111
+ req.end();
112
+ });
113
+ }
114
+ /** 钩子主流程:读 stdin → 编排 → 写 stdout / 回落。 */
115
+ async function main() {
116
+ const raw = await readStdin();
117
+ dbg(`钩子被触发。stdin 长度=${raw.length}`);
118
+ const outcome = await runPermissionHook(raw, {
119
+ isAway,
120
+ readLock: () => readDaemonLock(daemonLockPath()),
121
+ postAsk,
122
+ resolveLabel: defaultResolveLabel,
123
+ env: process.env,
124
+ log: dbg,
125
+ });
126
+ if (outcome.kind === 'fallthrough') {
127
+ dbg(`回落:${outcome.reason}`);
128
+ fallthrough();
129
+ }
130
+ process.stdout.write(outcome.text);
131
+ process.exit(0);
132
+ }
133
+ main().catch(() => {
134
+ // 兜底:主流程任何未预期异常一律回落,绝不误放行。
135
+ process.exit(0);
136
+ });
@@ -0,0 +1,79 @@
1
+ import { resolveAgentLabel } from './agentLabel.js';
2
+ import { buildPermissionQuestion, decideFromReply, serializeDecision, } from './permissionDecision.js';
3
+ /**
4
+ * 跑钩子编排(不 exit、不读 stdin 流)。
5
+ *
6
+ * @param stdinRaw 钩子 stdin 原文(JSON 字符串)。
7
+ * @param deps 可注入依赖。
8
+ * @returns stdout 文本或 fallthrough。
9
+ */
10
+ export async function runPermissionHook(stdinRaw, deps) {
11
+ const log = deps.log ?? (() => undefined);
12
+ // 1. 解析 stdin。
13
+ let input;
14
+ try {
15
+ input = JSON.parse(stdinRaw);
16
+ }
17
+ catch {
18
+ log('stdin 解析失败 → 回落');
19
+ return { kind: 'fallthrough', reason: 'stdin-invalid-json' };
20
+ }
21
+ const command = input.tool_input?.command;
22
+ log(`tool_name=${input.tool_name ?? '?'} command=${command ?? '(无)'}`);
23
+ if (typeof command !== 'string' || command.trim() === '') {
24
+ log('无 command → 回落');
25
+ return { kind: 'fallthrough', reason: 'no-command' };
26
+ }
27
+ // 2. 未开离开模式 → 立刻回落,不推钉钉、不读锁。
28
+ if (!deps.isAway()) {
29
+ log('未开离开模式 → 回落终端');
30
+ return { kind: 'fallthrough', reason: 'not-away' };
31
+ }
32
+ // 3. 读锁;不存在 → 回落。
33
+ const lock = deps.readLock();
34
+ if (lock === undefined) {
35
+ log('锁文件不存在/损坏 → 回落');
36
+ return { kind: 'fallthrough', reason: 'no-lock' };
37
+ }
38
+ log(`锁文件 OK:port=${lock.port}`);
39
+ // 4. POST /ask;任何异常/超时 → 回落。
40
+ let response;
41
+ try {
42
+ const label = deps.resolveLabel(deps.env, input.cwd);
43
+ response = await deps.postAsk(lock, buildPermissionQuestion(command, input.cwd), label);
44
+ log(`/ask 返回:kind=${response.kind} value=${response.value ?? ''}`);
45
+ }
46
+ catch (error) {
47
+ log(`/ask 请求失败 → 回落:${error instanceof Error ? error.message : String(error)}`);
48
+ return { kind: 'fallthrough', reason: 'post-ask-failed' };
49
+ }
50
+ // 5. 映射决定:仅明确 allow/deny 写 stdout;其余 fallthrough。
51
+ const decision = decideFromReply(response);
52
+ const out = serializeDecision(decision);
53
+ log(`决定:${decision.behavior},输出=${out || '(空→回落)'}`);
54
+ if (out === '') {
55
+ return { kind: 'fallthrough', reason: `decision-${decision.behavior}` };
56
+ }
57
+ return { kind: 'stdout', text: out };
58
+ }
59
+ /**
60
+ * 校验并解析 /ask 响应体为 AskResponse(纯函数,HTTP 适配层与单测共用)。
61
+ *
62
+ * @param body 响应体字符串。
63
+ * @returns AskResponse。
64
+ * @throws 当结构非法时抛错。
65
+ */
66
+ export function parseAskResponse(body) {
67
+ const raw = JSON.parse(body);
68
+ if (typeof raw !== 'object' || raw === null) {
69
+ throw new Error('/ask 响应非对象');
70
+ }
71
+ const obj = raw;
72
+ if (obj.kind !== 'answered' && obj.kind !== 'timeout') {
73
+ throw new Error('/ask 响应 kind 非法');
74
+ }
75
+ const value = typeof obj.value === 'string' ? obj.value : undefined;
76
+ return { kind: obj.kind, value };
77
+ }
78
+ /** 默认 resolveLabel:生产用 resolveAgentLabel。 */
79
+ export const defaultResolveLabel = resolveAgentLabel;
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "dingtalk-ask-mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "Human-in-the-loop MCP server:把 code agent(Claude Code / Codex)的决策与命令审批推送到钉钉,阻塞等你在手机回复。离开电脑也能远程拍板。",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "dingtalk",
9
+ "钉钉",
10
+ "claude-code",
11
+ "codex",
12
+ "human-in-the-loop",
13
+ "ask-human"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "姬煜",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://gitee.com/ji-yu66/agents-study.git",
20
+ "directory": "mcps/dingtalk-ask-mcp-server"
21
+ },
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "bin": {
32
+ "dingtalk-ask": "./dist/installCli.js",
33
+ "dingtalk-ask-mcp-server": "./dist/index.js",
34
+ "dingtalk-ask-install-hook": "./dist/installHook.js"
35
+ },
36
+ "publishConfig": {
37
+ "registry": "https://registry.npmjs.org/",
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "prepare": "npm run build",
43
+ "prepublishOnly": "npm run check",
44
+ "test": "vitest run",
45
+ "check": "npm run build && npm run test",
46
+ "install-hook": "node dist/installHook.js",
47
+ "away:on": "node dist/awayToggle.js on",
48
+ "away:off": "node dist/awayToggle.js off",
49
+ "away:status": "node dist/awayToggle.js status",
50
+ "daemon:status": "node dist/daemonCli.js status",
51
+ "daemon:stop": "node dist/daemonCli.js stop",
52
+ "verify:send": "tsx scripts/verify-send.ts",
53
+ "verify:roundtrip": "tsx scripts/verify-roundtrip.ts",
54
+ "verify:multiagent": "tsx scripts/verify-multiagent.ts",
55
+ "probe:stream": "tsx scripts/probe-stream.ts",
56
+ "probe:gateway": "tsx scripts/probe-gateway.ts"
57
+ },
58
+ "dependencies": {
59
+ "@modelcontextprotocol/sdk": "^1.29.0",
60
+ "axios": "^1.18.1",
61
+ "dingtalk-stream": "^2.1.6-beta.1",
62
+ "zod": "^4.2.1"
63
+ },
64
+ "devDependencies": {
65
+ "@types/node": "^24.10.2",
66
+ "typescript": "^5.9.3",
67
+ "tsx": "^4.19.2",
68
+ "vitest": "^4.0.14"
69
+ }
70
+ }