dingtalk-dws-mcp 1.0.2 → 1.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.
package/README.md CHANGED
@@ -4,18 +4,30 @@
4
4
 
5
5
  Agent:`instructions` / tool `description` / `prompts` / **Resources(`guides/*.md`)**。长期巡检:`dingtalk_patrol_setup` 备料 → 传装到主机 → 停。
6
6
 
7
+ 本目录为唯一工程根:
8
+
9
+ ```text
10
+ packages/mcp/dingtalk-dws-mcp/
11
+ src/ # MCP server(含 patrol-setup;build 时从 lib 同步 notify-delivery)
12
+ lib/notify-delivery/ # L0 投递(仓内唯一源;不单独发 npm)
13
+ host-patrol/ # 主机巡检 CLI 源码(pack:deploy 用;不进 MCP npm tarball)
14
+ guides/ # MCP Resources
15
+ ```
16
+
17
+ 部署产物仍使用 `deploy/linux-x64/runtime/patrol/`(与 `install.sh` 路径约定一致);勿与源码目录 `host-patrol/`、`src/patrol-setup/` 混淆。
18
+
7
19
  ## 构建
8
20
 
9
21
  ```bash
10
- cd packages/lib/notify-delivery && npm install && npm run build
11
- cd ../../mcp/dingtalk-dws-mcp && npm install && npm run build
12
- npm run pack:deploy
22
+ cd packages/mcp/dingtalk-dws-mcp
23
+ npm install && npm run build
24
+ npm run pack:deploy # 可选:生成 deploy/linux-x64 巡检包(仅 monorepo)
13
25
  ```
14
26
 
15
- `pack:deploy` 生成 `deploy/linux-x64/`(瘦身 patrol + 默认便携 Node `bin/node`,禁 devDeps)。
16
- 可选:`DINGTALK_PATROL_INCLUDE_NODE=0`、`DINGTALK_PATROL_NODE_URL`、`DINGTALK_PATROL_INCLUDE_DWS=0`(默认尝试打入 linux ELF dws;Windows 打包机需 `DINGTALK_PATROL_DWS_PATH` 指向 ELF)。
17
- `dingtalk_patrol_setup` 只写 staging(含本机已验证投递凭据);传装用 host-execution SSH,目标机跑 `install.sh`。可选 `deploy.sh` 为直连 SSH helper。
27
+ `npm publish` `files` 仅含 `dist/src` + `guides` + README/LICENSE;**不会**把 `host-patrol/`、`lib/`、`deploy/` 打进 registry。
28
+
29
+ `pack:deploy` **仅 monorepo**(需要 `host-patrol/`、`lib/`、`scripts/`)。npx/registry 没有这些目录;`patrol_bundle.available=false` 属预期。默认不打便携 Node;空气间隙设 `DINGTALK_PATROL_INCLUDE_NODE=1`。可选 `DINGTALK_PATROL_NODE_URL`、`DINGTALK_PATROL_INCLUDE_DWS=0`。Windows 可从 `dingtalk-workspace-cli` 抽出 linux ELF `dws`(或设 `DINGTALK_PATROL_DWS_PATH`)。传装跟 `copy_plan`;可选 `deploy.sh` 为直连 SSH helper。
18
30
 
19
- 配置示例(npx):[`mcp/dingtalk-dws-mcp/mcp.example.json`](../../../mcp/dingtalk-dws-mcp/mcp.example.json)。npm:`dingtalk-dws-mcp@1.0.2`。
31
+ 配置示例(npx):[`mcp/dingtalk-dws-mcp/mcp.example.json`](../../../mcp/dingtalk-dws-mcp/mcp.example.json)。npm:`dingtalk-dws-mcp@1.1.0`。
20
32
 
21
33
  规格(开发者):[RFC-0007](../../../docs/rfc/RFC-0007-dingtalk-dws-mcp.md)、[RFC-0008](../../../docs/rfc/RFC-0008-dingtalk-notify-patrol.md)。
@@ -1,7 +1,22 @@
1
1
  import type { AppConfig, RouteName, RouteType, TargetCandidate } from "../types.js";
2
+ /** Persisted bind record. Both fields required. */
3
+ export type BoundTarget = {
4
+ id: string;
5
+ name: string;
6
+ };
7
+ /** Unified route view for doctor / list_routes / send. Bound iff name != null. */
8
+ export type RouteBindingView = {
9
+ name: string | null;
10
+ id: string | null;
11
+ };
2
12
  export declare function candidateRef(candidate: TargetCandidate): string;
3
- export declare function readTargets(config: AppConfig): Record<string, string>;
4
- export declare function writeTarget(config: AppConfig, route: RouteName, targetId: string): void;
13
+ /** Read valid bindings only. Bare strings / partial objects are invalid → omitted (unbound). No migration. */
14
+ export declare function readBoundTargets(config: AppConfig): Partial<Record<RouteName, BoundTarget>>;
15
+ export declare function readBoundTarget(config: AppConfig, route: RouteName): BoundTarget | null;
16
+ export declare function writeTarget(config: AppConfig, route: RouteName, target: BoundTarget): void;
5
17
  export declare function resolveRouteTarget(config: AppConfig, route: RouteName): string;
18
+ export declare function describeRouteBinding(config: AppConfig, route: RouteName): RouteBindingView;
19
+ export declare function describeRoutesMap(config: AppConfig, routes: readonly RouteName[]): Record<string, RouteBindingView>;
6
20
  export declare function routeType(route: RouteName): RouteType;
21
+ /** Internal: bound iff display name is present (same rule as API surfaces). */
7
22
  export declare function isRouteConfigured(config: AppConfig, route: RouteName): boolean;
@@ -5,21 +5,47 @@ import { DingtalkDwsError } from "../errors.js";
5
5
  export function candidateRef(candidate) {
6
6
  return `candidate_${createHash("sha256").update(`${candidate.type}\n${candidate.target_id}`).digest("hex")}`;
7
7
  }
8
- export function readTargets(config) {
8
+ function parseBoundTarget(raw) {
9
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
10
+ return null;
11
+ const record = raw;
12
+ const id = typeof record.id === "string" ? record.id.trim() : "";
13
+ const name = typeof record.name === "string" ? record.name.trim() : "";
14
+ if (!id || !name)
15
+ return null;
16
+ return { id, name };
17
+ }
18
+ /** Read valid bindings only. Bare strings / partial objects are invalid → omitted (unbound). No migration. */
19
+ export function readBoundTargets(config) {
9
20
  try {
10
21
  const parsed = JSON.parse(fs.readFileSync(config.targetsPath, "utf8"));
11
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
12
- return parsed;
22
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
23
+ return {};
24
+ const out = {};
25
+ for (const route of ["notify_group", "notify_user"]) {
26
+ const bound = parseBoundTarget(parsed[route]);
27
+ if (bound)
28
+ out[route] = bound;
29
+ }
30
+ return out;
13
31
  }
14
32
  catch {
15
- // first bind
33
+ return {};
16
34
  }
17
- return {};
18
35
  }
19
- export function writeTarget(config, route, targetId) {
36
+ export function readBoundTarget(config, route) {
37
+ return readBoundTargets(config)[route] ?? null;
38
+ }
39
+ export function writeTarget(config, route, target) {
40
+ const id = target.id.trim();
41
+ const name = target.name.trim();
42
+ if (!id || !name) {
43
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "bind requires non-empty id and name");
44
+ }
20
45
  fs.mkdirSync(path.dirname(config.targetsPath), { recursive: true, mode: 0o700 });
21
- const targets = readTargets(config);
22
- targets[route] = targetId;
46
+ // Rewrite from valid bindings only — invalid on-disk slots are dropped, not upgraded.
47
+ const targets = readBoundTargets(config);
48
+ targets[route] = { id, name };
23
49
  fs.writeFileSync(config.targetsPath, `${JSON.stringify(targets, null, 2)}\n`, { mode: 0o600 });
24
50
  }
25
51
  export function resolveRouteTarget(config, route) {
@@ -29,16 +55,32 @@ export function resolveRouteTarget(config, route) {
29
55
  }
30
56
  return "webhook";
31
57
  }
32
- const target = readTargets(config)[route];
58
+ const target = readBoundTarget(config, route);
33
59
  if (!target)
34
60
  throw new DingtalkDwsError("INVALID_ARGUMENT", `Route ${route} has no bound target`);
35
- return target;
61
+ return target.id;
62
+ }
63
+ export function describeRouteBinding(config, route) {
64
+ if (config.adapter === "webhook") {
65
+ if (route !== "notify_group" || !config.webhookUrl)
66
+ return { name: null, id: null };
67
+ return { name: "webhook", id: null };
68
+ }
69
+ const bound = readBoundTarget(config, route);
70
+ if (!bound)
71
+ return { name: null, id: null };
72
+ return { name: bound.name, id: bound.id };
73
+ }
74
+ export function describeRoutesMap(config, routes) {
75
+ const out = {};
76
+ for (const route of routes)
77
+ out[route] = describeRouteBinding(config, route);
78
+ return out;
36
79
  }
37
80
  export function routeType(route) {
38
81
  return route === "notify_group" ? "group" : "user";
39
82
  }
83
+ /** Internal: bound iff display name is present (same rule as API surfaces). */
40
84
  export function isRouteConfigured(config, route) {
41
- if (config.adapter === "webhook")
42
- return route === "notify_group" && Boolean(config.webhookUrl);
43
- return Boolean(readTargets(config)[route]);
85
+ return describeRouteBinding(config, route).name != null;
44
86
  }
@@ -1,11 +1,31 @@
1
1
  import type { AppConfig, RouteName } from "../types.js";
2
- /** Agent-filled args (not mcp.json forms). Delivery credentials come from MCP env → env.sh. */
2
+ declare const DELIVER_POLICIES: readonly ["always", "on_status_change", "only_statuses"];
3
+ type DeliverPolicyKind = (typeof DELIVER_POLICIES)[number];
4
+ declare const TERMINATION_POLICIES: readonly ["none", "status_match", "manual"];
5
+ type TerminationPolicyKind = (typeof TERMINATION_POLICIES)[number];
6
+ export type PatrolSetupIntent = "always" | "long_monitor" | "stop_on_critical" | "on_event";
7
+ export type PatrolSetupDeliver = {
8
+ policy?: DeliverPolicyKind;
9
+ statuses?: string[];
10
+ };
11
+ export type PatrolSetupTermination = {
12
+ policy?: TerminationPolicyKind;
13
+ status?: string;
14
+ on?: "first" | "until";
15
+ terminate_on_delivery_failure?: boolean;
16
+ };
17
+ /** Agent-filled args (not mcp.json forms). Delivery credentials come from MCP env -> env.sh. */
3
18
  export type PatrolSetupInput = {
4
19
  name: string;
5
20
  cron?: string;
6
21
  probe_command?: string;
7
22
  probe_script?: string;
8
23
  route?: RouteName;
24
+ /** Recipe alias for Agent. Explicit deliver/termination override the alias. */
25
+ intent?: PatrolSetupIntent;
26
+ watch_path?: string;
27
+ deliver?: PatrolSetupDeliver;
28
+ termination?: PatrolSetupTermination;
9
29
  /** Soft mode: allow missing bind/bundle (tests / preview). Always stages only. */
10
30
  dry_run?: boolean;
11
31
  };
@@ -21,7 +41,8 @@ export declare function readBundleInfo(env?: NodeJS.ProcessEnv): {
21
41
  };
22
42
  /**
23
43
  * Stage offline patrol materials only. Does not SSH/scp.
24
- * Agent copies bundle_root + staging_dir to the host (host-execution or SSH), then runs install.sh there.
44
+ * Prefer copy_plan.artifact (one .tgz). Two-dir copy is fallback when tar packing fails.
25
45
  */
26
46
  export declare function runPatrolSetup(config: AppConfig, input: PatrolSetupInput, env?: NodeJS.ProcessEnv): Promise<Record<string, unknown>>;
27
47
  export declare function bundleFingerprintForDoctor(env?: NodeJS.ProcessEnv): Record<string, unknown>;
48
+ export {};
@@ -0,0 +1,386 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { DEFAULT_PATROL_MESSAGE_BODY, DEFAULT_PATROL_MESSAGE_TITLE, } from "../notify-delivery/index.js";
6
+ import { DingtalkDwsError } from "../errors.js";
7
+ import { isRouteConfigured, readBoundTarget } from "../notify/bind.js";
8
+ import { mcpPackageRoot, readPackageVersion } from "../version.js";
9
+ const OBSERVATION_STATUSES = ["ok", "warning", "critical", "unknown"];
10
+ const DELIVER_POLICIES = ["always", "on_status_change", "only_statuses"];
11
+ const TERMINATION_POLICIES = ["none", "status_match", "manual"];
12
+ const DEFAULT_CRON = "*/15 * * * *";
13
+ const DEFAULT_PROBE = `#!/usr/bin/env bash
14
+ set -euo pipefail
15
+ printf '%s\\n' '{"status":"ok","summary":"【进度】示例巡检正常\\n【监控】请替换为真实检查","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}'
16
+ `;
17
+ export function resolveBundleRoot(env = process.env) {
18
+ const override = env.DINGTALK_PATROL_BUNDLE_ROOT?.trim();
19
+ if (override)
20
+ return path.resolve(override);
21
+ return path.join(mcpPackageRoot(), "deploy", "linux-x64");
22
+ }
23
+ export function readBundleInfo(env = process.env) {
24
+ const bundle_root = resolveBundleRoot(env);
25
+ const manifestPath = path.join(bundle_root, "manifest.json");
26
+ const ready = fs.existsSync(manifestPath) && fs.existsSync(path.join(bundle_root, "runtime", "patrol"));
27
+ if (!ready) {
28
+ return {
29
+ available: false,
30
+ bundle_root,
31
+ fingerprint: null,
32
+ server_version: null,
33
+ includes_node: false,
34
+ includes_dws: false,
35
+ message: `Patrol bundle missing at ${bundle_root}. ` +
36
+ `pack:deploy is monorepo-only (clone agent-compose, cd packages/mcp/dingtalk-dws-mcp, npm run pack:deploy). ` +
37
+ `npx installs do not include host-patrol/ or deploy artifacts.`,
38
+ };
39
+ }
40
+ try {
41
+ const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
42
+ return {
43
+ available: true,
44
+ bundle_root,
45
+ fingerprint: typeof m.fingerprint === "string" ? m.fingerprint : null,
46
+ server_version: typeof m.server_version === "string" ? m.server_version : null,
47
+ includes_node: Boolean(m.includes_node),
48
+ includes_dws: Boolean(m.includes_dws),
49
+ message: "bundle ready",
50
+ };
51
+ }
52
+ catch {
53
+ return {
54
+ available: false,
55
+ bundle_root,
56
+ fingerprint: null,
57
+ server_version: null,
58
+ includes_node: false,
59
+ includes_dws: false,
60
+ message: `Invalid manifest at ${manifestPath}`,
61
+ };
62
+ }
63
+ }
64
+ function shellQuote(value) {
65
+ return `'${value.replace(/'/g, `'\\''`)}'`;
66
+ }
67
+ function writeLf(filePath, content, mode) {
68
+ const body = content.replace(/\r\n/g, "\n");
69
+ if (mode === undefined)
70
+ fs.writeFileSync(filePath, body, "utf8");
71
+ else
72
+ fs.writeFileSync(filePath, body, { mode });
73
+ }
74
+ /** Sync local notify credentials (already verified by doctor) into target env.sh. Not user OAuth.
75
+ * Never copy laptop DINGTALK_DWS_COMMAND / config.dwsCommand — that path is Agent-host local
76
+ * (Windows .exe or Linux absolute) and is wrong on the patrol target. Target resolves `dws` via
77
+ * install runtime PATH (bundled linux ELF) or system dws. Optional target-only override:
78
+ * DINGTALK_PATROL_HOST_DWS (path that exists on the target machine).
79
+ */
80
+ function writeEnvFile(filePath, config, env) {
81
+ const lines = ["# Generated by dingtalk_patrol_setup — delivery credentials for target host"];
82
+ const put = (key, value) => {
83
+ if (value)
84
+ lines.push(`export ${key}=${shellQuote(value)}`);
85
+ };
86
+ put("DINGTALK_CLIENT_ID", config.clientId ?? env.DINGTALK_CLIENT_ID);
87
+ put("DINGTALK_CLIENT_SECRET", config.clientSecret ?? env.DINGTALK_CLIENT_SECRET);
88
+ put("DINGTALK_ROBOT_CODE", config.robotCode ?? env.DINGTALK_ROBOT_CODE);
89
+ put("DINGTALK_WEBHOOK_URL", config.webhookUrl ?? env.DINGTALK_WEBHOOK_URL);
90
+ put("DINGTALK_WEBHOOK_SECRET", config.webhookSecret ?? env.DINGTALK_WEBHOOK_SECRET);
91
+ const hostDws = env.DINGTALK_PATROL_HOST_DWS?.trim();
92
+ if (hostDws)
93
+ put("DINGTALK_DWS_COMMAND", hostDws);
94
+ writeLf(filePath, `${lines.join("\n")}\n`, 0o600);
95
+ }
96
+ function pickRoute(config, preferred) {
97
+ if (config.adapter === "webhook")
98
+ return "notify_group";
99
+ if (preferred) {
100
+ if (!isRouteConfigured(config, preferred)) {
101
+ throw new DingtalkDwsError("INVALID_ARGUMENT", `Route ${preferred} is not bound`);
102
+ }
103
+ return preferred;
104
+ }
105
+ if (isRouteConfigured(config, "notify_group"))
106
+ return "notify_group";
107
+ if (isRouteConfigured(config, "notify_user"))
108
+ return "notify_user";
109
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "Bind a notify target first (or use webhook)");
110
+ }
111
+ const INTENT_PRESETS = {
112
+ always: { deliver: { policy: "always" }, termination: { policy: "none" } },
113
+ long_monitor: {
114
+ deliver: { policy: "on_status_change", statuses: ["warning", "critical"] },
115
+ termination: { policy: "none" },
116
+ },
117
+ stop_on_critical: {
118
+ deliver: { policy: "always" },
119
+ termination: { policy: "status_match", status: "critical", on: "first" },
120
+ },
121
+ on_event: { deliver: { policy: "always" }, termination: { policy: "none" } },
122
+ };
123
+ function policiesFromIntent(intent) {
124
+ return INTENT_PRESETS[intent];
125
+ }
126
+ function resolvePolicies(input) {
127
+ const intent = input.intent ?? "always";
128
+ const preset = policiesFromIntent(intent);
129
+ return {
130
+ intent,
131
+ deliver: normalizeDeliver(input.deliver ?? preset.deliver),
132
+ termination: normalizeTermination(input.termination ?? preset.termination),
133
+ };
134
+ }
135
+ function writeJobArchive(options) {
136
+ const installSrc = path.join(options.bundleRoot, "install.sh");
137
+ if (!fs.existsSync(installSrc))
138
+ return null;
139
+ const packDir = fs.mkdtempSync(path.join(os.tmpdir(), "patrol-job-"));
140
+ try {
141
+ writeLf(path.join(packDir, "install.sh"), fs.readFileSync(installSrc, "utf8"), 0o755);
142
+ const manifest = path.join(options.bundleRoot, "manifest.json");
143
+ if (fs.existsSync(manifest))
144
+ fs.copyFileSync(manifest, path.join(packDir, "manifest.json"));
145
+ const runtime = path.join(options.bundleRoot, "runtime");
146
+ const includes_runtime = fs.existsSync(runtime);
147
+ if (includes_runtime)
148
+ fs.cpSync(runtime, path.join(packDir, "runtime"), { recursive: true });
149
+ fs.cpSync(options.stagingRoot, path.join(packDir, "staging"), { recursive: true });
150
+ fs.mkdirSync(path.dirname(options.outFile), { recursive: true });
151
+ if (fs.existsSync(options.outFile))
152
+ fs.unlinkSync(options.outFile);
153
+ const entries = ["install.sh", "staging"];
154
+ if (fs.existsSync(path.join(packDir, "manifest.json")))
155
+ entries.push("manifest.json");
156
+ if (includes_runtime)
157
+ entries.push("runtime");
158
+ const dest = options.outFile.replace(/\\/g, "/");
159
+ const cwd = packDir.replace(/\\/g, "/");
160
+ execFileSync("tar", ["-czf", dest, "-C", cwd, ...entries], { windowsHide: true });
161
+ return { bytes: fs.statSync(options.outFile).size, includes_runtime };
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ finally {
167
+ fs.rmSync(packDir, { recursive: true, force: true });
168
+ }
169
+ }
170
+ function isObservationStatus(value) {
171
+ return OBSERVATION_STATUSES.includes(value);
172
+ }
173
+ function normalizeDeliver(input) {
174
+ const policy = input?.policy ?? "always";
175
+ if (!DELIVER_POLICIES.includes(policy)) {
176
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "deliver.policy must be always | on_status_change | only_statuses");
177
+ }
178
+ const statuses = (input?.statuses ?? []).filter((s) => typeof s === "string" && isObservationStatus(s));
179
+ if (input?.statuses && input.statuses.length !== statuses.length) {
180
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "deliver.statuses must be ok|warning|critical|unknown");
181
+ }
182
+ if (policy === "only_statuses" && statuses.length === 0) {
183
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "deliver.statuses is required when deliver.policy is only_statuses");
184
+ }
185
+ return statuses.length ? { policy, statuses } : { policy };
186
+ }
187
+ function normalizeTermination(input) {
188
+ const policy = input?.policy ?? "none";
189
+ if (!TERMINATION_POLICIES.includes(policy)) {
190
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.policy must be none | status_match | manual");
191
+ }
192
+ if (policy !== "status_match") {
193
+ return { policy };
194
+ }
195
+ const status = input?.status;
196
+ if (!status || !isObservationStatus(status)) {
197
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.status must be ok|warning|critical|unknown");
198
+ }
199
+ const on = input?.on === "until" ? "until" : "first";
200
+ return {
201
+ policy,
202
+ status,
203
+ on,
204
+ terminate_on_delivery_failure: input?.terminate_on_delivery_failure === true,
205
+ };
206
+ }
207
+ function yamlBlockDeliver(deliver) {
208
+ const lines = ["deliver:", ` policy: ${deliver.policy}`];
209
+ if (deliver.statuses?.length) {
210
+ lines.push(` statuses: [${deliver.statuses.map((s) => JSON.stringify(s)).join(", ")}]`);
211
+ }
212
+ return lines.join("\n");
213
+ }
214
+ function yamlBlockTermination(termination) {
215
+ const lines = ["termination:", ` policy: ${termination.policy}`];
216
+ if (termination.policy === "status_match" && termination.status) {
217
+ lines.push(` status: ${termination.status}`);
218
+ lines.push(` on: ${termination.on ?? "first"}`);
219
+ if (termination.terminate_on_delivery_failure) {
220
+ lines.push(" terminate_on_delivery_failure: true");
221
+ }
222
+ }
223
+ return lines.join("\n");
224
+ }
225
+ function buildYaml(options) {
226
+ const delivery = [` type: ${options.deliveryType}`];
227
+ if (options.target)
228
+ delivery.push(` ${options.target.field}: ${JSON.stringify(options.target.id)}`);
229
+ const body = DEFAULT_PATROL_MESSAGE_BODY.split("\n").map((l) => ` ${l}`).join("\n");
230
+ const trigger = options.watchPath
231
+ ? `trigger:
232
+ type: event
233
+ watch:
234
+ path: ${JSON.stringify(options.watchPath)}`
235
+ : `cron: ${JSON.stringify(options.cron ?? "*/15 * * * *")}`;
236
+ return `name: ${options.name}
237
+ ${trigger}
238
+ probe:
239
+ exec:
240
+ command: ${JSON.stringify(options.probeCommand)}
241
+ timeout_sec: 60
242
+ delivery:
243
+ ${delivery.join("\n")}
244
+ ${yamlBlockDeliver(options.deliver)}
245
+ ${yamlBlockTermination(options.termination)}
246
+ template:
247
+ title: ${JSON.stringify(DEFAULT_PATROL_MESSAGE_TITLE)}
248
+ body: |
249
+ ${body}
250
+ `;
251
+ }
252
+ /**
253
+ * Stage offline patrol materials only. Does not SSH/scp.
254
+ * Prefer copy_plan.artifact (one .tgz). Two-dir copy is fallback when tar packing fails.
255
+ */
256
+ export async function runPatrolSetup(config, input, env = process.env) {
257
+ const name = input.name.trim();
258
+ if (!name || name.includes("/") || name.includes("\\") || name.includes("..")) {
259
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "name must be a single path segment");
260
+ }
261
+ if (!config.adapter) {
262
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "Set DINGTALK_CLIENT_ID/SECRET or DINGTALK_WEBHOOK_URL in MCP env");
263
+ }
264
+ const { intent, deliver, termination } = resolvePolicies(input);
265
+ if (intent === "on_event" && !input.watch_path?.trim()) {
266
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "watch_path is required for intent=on_event (host file or directory; EventTrigger uses fs.watch, not cron)");
267
+ }
268
+ const watchPath = intent === "on_event" ? input.watch_path.trim() : undefined;
269
+ const cron = watchPath ? undefined : (input.cron?.trim() || DEFAULT_CRON);
270
+ const bundle = readBundleInfo(env);
271
+ if (!bundle.available && !input.dry_run) {
272
+ throw new DingtalkDwsError("ADAPTER_UNAVAILABLE", bundle.message);
273
+ }
274
+ const route = pickRoute(config, input.route);
275
+ const deliveryType = config.adapter;
276
+ let target;
277
+ let targetName;
278
+ if (deliveryType === "dws") {
279
+ const bound = readBoundTarget(config, route);
280
+ if (!bound && !input.dry_run) {
281
+ throw new DingtalkDwsError("INVALID_ARGUMENT", `Bind ${route} before setup`);
282
+ }
283
+ if (bound) {
284
+ targetName = bound.name;
285
+ target = route === "notify_group"
286
+ ? { field: "open_conversation_id", id: bound.id }
287
+ : { field: "user_id", id: bound.id };
288
+ }
289
+ }
290
+ const stagingRoot = path.join(env.DINGTALK_PATROL_STAGING_DIR?.trim()
291
+ ? path.resolve(env.DINGTALK_PATROL_STAGING_DIR)
292
+ : path.join(os.tmpdir(), "dingtalk-patrol-staging"), name);
293
+ fs.mkdirSync(path.join(stagingRoot, "probes"), { recursive: true, mode: 0o700 });
294
+ const probeCommand = input.probe_command?.trim() || "./probes/example.sh";
295
+ const probeFile = path.join(stagingRoot, "probes", path.basename(probeCommand) === probeCommand ? "example.sh" : path.basename(probeCommand));
296
+ const script = input.probe_script ?? DEFAULT_PROBE;
297
+ writeLf(probeFile, script.endsWith("\n") ? script : `${script}\n`, 0o755);
298
+ const yamlPath = path.join(stagingRoot, "patrol.yaml");
299
+ const envPath = path.join(stagingRoot, "env.sh");
300
+ writeLf(yamlPath, buildYaml({
301
+ name,
302
+ cron,
303
+ watchPath,
304
+ probeCommand: probeCommand.includes("/") || probeCommand.includes("\\")
305
+ ? probeCommand
306
+ : `./probes/${path.basename(probeFile)}`,
307
+ deliveryType,
308
+ target,
309
+ deliver,
310
+ termination,
311
+ }));
312
+ writeEnvFile(envPath, config, env);
313
+ const artifactPath = path.join(path.dirname(stagingRoot), `${name}.tgz`);
314
+ const archive = bundle.available
315
+ ? writeJobArchive({ bundleRoot: bundle.bundle_root, stagingRoot, outFile: artifactPath })
316
+ : null;
317
+ const response = {
318
+ ok: true,
319
+ name,
320
+ cron,
321
+ route,
322
+ intent,
323
+ watch_path: watchPath,
324
+ trigger: watchPath ? { type: "event", watch: { path: watchPath } } : { type: "cron", cron },
325
+ deliver,
326
+ termination,
327
+ next_action: "install_on_host",
328
+ staging_dir: stagingRoot,
329
+ bundle_root: bundle.bundle_root,
330
+ bundle_fingerprint: bundle.fingerprint,
331
+ includes_node: bundle.includes_node,
332
+ includes_dws: bundle.includes_dws,
333
+ install_hint: archive
334
+ ? `Copy one file ${artifactPath} to the host, tar -xzf, then ./install.sh ./staging. LAN hosts: local SCP. Do not send RFC1918 IPs via ACCESS.`
335
+ : "Copy bundle_root and staging_dir to the host, then run install.sh <staging_on_host>",
336
+ copy_plan: archive
337
+ ? {
338
+ artifact: artifactPath,
339
+ bytes: archive.bytes,
340
+ includes_runtime: archive.includes_runtime,
341
+ remote_install: "tar -xzf <job.tgz> && ./install.sh ./staging",
342
+ }
343
+ : {
344
+ bundle_root: bundle.bundle_root,
345
+ staging_dir: stagingRoot,
346
+ remote_install: "install.sh <staging_on_host>",
347
+ },
348
+ server_version: readPackageVersion(),
349
+ paths: { yaml: yamlPath, env: envPath, probe: probeFile, artifact: archive ? artifactPath : undefined },
350
+ };
351
+ if (targetName)
352
+ response.target_name = targetName;
353
+ if (deliveryType === "dws" && bundle.available && !bundle.includes_dws) {
354
+ response.warning =
355
+ "bundle includes_dws=false: target needs system dws on PATH, or re-pack with linux ELF (DINGTALK_PATROL_DWS_PATH)";
356
+ }
357
+ const serialized = JSON.stringify(response);
358
+ for (const secret of [config.clientSecret, config.webhookSecret, env.DINGTALK_CLIENT_SECRET, env.DINGTALK_WEBHOOK_SECRET]) {
359
+ if (secret && serialized.includes(secret)) {
360
+ throw new DingtalkDwsError("INTERNAL_ERROR", "refusing to return secrets");
361
+ }
362
+ }
363
+ return response;
364
+ }
365
+ export function bundleFingerprintForDoctor(env = process.env) {
366
+ const info = readBundleInfo(env);
367
+ let next_action = null;
368
+ if (!info.available) {
369
+ next_action =
370
+ "Host patrol bundle missing. In this repo: npm run pack:deploy and set DINGTALK_PATROL_BUNDLE_ROOT. npx installs have no deploy artifacts — do not dry_run as a substitute for packing.";
371
+ }
372
+ else if (!info.includes_dws) {
373
+ next_action =
374
+ "Bundle has no linux dws ELF. Re-run pack:deploy on a machine with dingtalk-workspace-cli (or set DINGTALK_PATROL_DWS_PATH). Target otherwise needs system dws.";
375
+ }
376
+ return {
377
+ available: info.available,
378
+ fingerprint: info.fingerprint,
379
+ server_version: info.server_version ?? readPackageVersion(),
380
+ includes_node: info.includes_node,
381
+ includes_dws: info.includes_dws,
382
+ path: info.bundle_root,
383
+ message: info.message,
384
+ next_action,
385
+ };
386
+ }
@@ -2,12 +2,10 @@ const NOTIFY_RECIPE = `One-shot DingTalk notify (user-selected recipe):
2
2
  1. dingtalk_doctor → follow next_action
3
3
  2. Managed: search → bind → dingtalk_send (route, title, content)
4
4
  3. Webhook: dingtalk_send with route=notify_group only`;
5
- const PATROL_RECIPE = `Host Patrol Runtime (user-selected recipe):
5
+ const PATROL_RECIPE = `Host Job Runtime (RFC-0008; user-selected recipe):
6
6
  1. dingtalk_doctor (credentials in MCP env only)
7
- 2. Managed: bind notify target if needed
8
- 3. From conversation: host + what to check + how often call dingtalk_patrol_setup once (stages materials + delivery credentials)
9
- 4. Copy bundle_root + staging_dir to the host (host-execution if available, else SSH with user-provided IP/user/password or key), run install.sh on the host
10
- 5. Stop — do not download Node on the laptop`;
7
+ 2. Managed: search bind if needed. Search empty (next_action=ask_user): stop and ask the user; do not guess another name.
8
+ 3. dingtalk_patrol_setup once (intent from conversation; on_event needs watch_path). Follow copy_plan. Stop no session loop or dingtalk_send.`;
11
9
  export function listPromptDefinitions(_surface) {
12
10
  return [
13
11
  {
@@ -1,7 +1,7 @@
1
1
  import type { CommandRunner } from "./internal/exec.js";
2
2
  import type { AdapterKind, AppConfig, RouteName, RouteType, SendInput } from "./types.js";
3
3
  import { IdempotencyStore } from "./notify/idempotency-store.js";
4
- import { type PatrolSetupInput } from "./patrol/setup.js";
4
+ import { type PatrolSetupInput } from "./patrol-setup/setup.js";
5
5
  export declare class DingtalkDwsService {
6
6
  private readonly config;
7
7
  private readonly store;
@@ -4,10 +4,10 @@ import { sendWebhook, validateWebhookConfig } from "./delivery/webhook.js";
4
4
  import { DingtalkDwsError } from "./errors.js";
5
5
  import { dwsAuthLogin, dwsAuthStatus, probeDws } from "./internal/dws-auth-command.js";
6
6
  import { sendRobotMessage } from "./internal/dws-robot-command.js";
7
- import { candidateRef, isRouteConfigured, resolveRouteTarget, routeType, writeTarget } from "./notify/bind.js";
7
+ import { candidateRef, describeRouteBinding, describeRoutesMap, resolveRouteTarget, routeType, writeTarget, } from "./notify/bind.js";
8
8
  import { validateSendContent } from "./notify/content.js";
9
9
  import { sha256 } from "./notify/idempotency-store.js";
10
- import { bundleFingerprintForDoctor, runPatrolSetup } from "./patrol/setup.js";
10
+ import { bundleFingerprintForDoctor, runPatrolSetup } from "./patrol-setup/setup.js";
11
11
  import { readPackageVersion } from "./version.js";
12
12
  export class DingtalkDwsService {
13
13
  config;
@@ -41,12 +41,23 @@ export class DingtalkDwsService {
41
41
  const dwsReady = probe.installed && auth.authenticated === true;
42
42
  let notifyReady = false;
43
43
  let notifyMessage = this.config.adapterError ?? "Notify credentials are not configured";
44
+ const routeViews = this.config.adapter === "webhook"
45
+ ? describeRoutesMap(this.config, ["notify_group"])
46
+ : this.config.adapter === "dws"
47
+ ? describeRoutesMap(this.config, ["notify_group", "notify_user"])
48
+ : {};
44
49
  if (this.config.adapter === "dws") {
45
- notifyReady = Boolean(this.config.clientId && this.config.clientSecret)
46
- && (isRouteConfigured(this.config, "notify_group") || isRouteConfigured(this.config, "notify_user"));
47
- notifyMessage = notifyReady
48
- ? "Managed notify is ready"
49
- : "Configure robot credentials and bind at least one route";
50
+ const credsOk = Boolean(this.config.clientId && this.config.clientSecret);
51
+ const routeOk = Object.values(routeViews).some((view) => view.name != null);
52
+ notifyReady = credsOk && routeOk;
53
+ if (!credsOk)
54
+ notifyMessage = "Configure robot credentials and bind at least one route";
55
+ else if (!routeOk) {
56
+ notifyMessage =
57
+ "Bind notify_group or notify_user with {id,name}; call list_routes to verify the group/user name";
58
+ }
59
+ else
60
+ notifyMessage = "Managed notify is ready";
50
61
  }
51
62
  else if (this.config.adapter === "webhook") {
52
63
  const validation = validateWebhookConfig(this.config);
@@ -88,17 +99,9 @@ export class DingtalkDwsService {
88
99
  },
89
100
  notify: {
90
101
  adapter: this.config.adapter,
91
- credentials_configured: this.config.adapter === "webhook"
92
- ? Boolean(this.config.webhookUrl)
93
- : Boolean(this.config.clientId && this.config.clientSecret),
94
102
  ready: notifyReady,
95
103
  message: notifyMessage,
96
- routes: this.config.adapter === "webhook"
97
- ? { notify_group: { configured: isRouteConfigured(this.config, "notify_group") } }
98
- : {
99
- notify_group: { configured: isRouteConfigured(this.config, "notify_group") },
100
- notify_user: { configured: isRouteConfigured(this.config, "notify_user") },
101
- },
104
+ routes: routeViews,
102
105
  },
103
106
  next_action,
104
107
  };
@@ -128,15 +131,17 @@ export class DingtalkDwsService {
128
131
  const names = this.config.adapter === "webhook"
129
132
  ? ["notify_group"]
130
133
  : ["notify_group", "notify_user"];
131
- const routes = names.map((name) => ({
132
- name,
133
- type: routeType(name),
134
- configured: isRouteConfigured(this.config, name),
135
- description: name === "notify_group" ? "Notify a DingTalk group" : "Notify a DingTalk user",
136
- }));
137
134
  return {
138
135
  adapter: this.config.adapter,
139
- routes,
136
+ routes: names.map((route) => {
137
+ const binding = describeRouteBinding(this.config, route);
138
+ return {
139
+ route,
140
+ type: routeType(route),
141
+ name: binding.name,
142
+ id: binding.id,
143
+ };
144
+ }),
140
145
  };
141
146
  }
142
147
  /** Adapter used to shape the public MCP tool surface. */
@@ -153,12 +158,22 @@ export class DingtalkDwsService {
153
158
  for (const type of types) {
154
159
  candidates.push(...await searchTargets(this.config, type, input.query, input.limit ?? 10, this.runner, this.env));
155
160
  }
161
+ const mapped = candidates.map((candidate) => ({
162
+ type: candidate.type,
163
+ name: candidate.name,
164
+ candidate_ref: candidateRef(candidate),
165
+ }));
156
166
  return {
157
- candidates: candidates.map((candidate) => ({
158
- type: candidate.type,
159
- name: candidate.name,
160
- candidate_ref: candidateRef(candidate),
161
- })),
167
+ query: input.query,
168
+ type: input.type ?? "all",
169
+ candidates: mapped,
170
+ next_action: mapped.length === 0 ? "ask_user" : "bind",
171
+ ...(mapped.length === 0
172
+ ? {
173
+ message: `No DingTalk target matched ${JSON.stringify(input.query)}. ` +
174
+ "Ask the user for the exact display name shown in DingTalk. Do not search a guessed name or bind a similar candidate.",
175
+ }
176
+ : {}),
162
177
  };
163
178
  }
164
179
  async configureRouteTarget(input) {
@@ -172,21 +187,29 @@ export class DingtalkDwsService {
172
187
  for (const type of types) {
173
188
  candidates.push(...await searchTargets(this.config, type, input.query, input.candidate_ref ? 20 : 2, this.runner, this.env));
174
189
  }
190
+ if (candidates.length === 0) {
191
+ throw new DingtalkDwsError("INVALID_ARGUMENT", `No DingTalk target matched ${JSON.stringify(input.query)}. Ask the user for the exact display name. Do not bind a similar candidate.`);
192
+ }
175
193
  const candidate = input.candidate_ref
176
194
  ? candidates.find((item) => candidateRef(item) === input.candidate_ref)
177
195
  : candidates.length === 1 ? candidates[0] : undefined;
178
196
  if (!candidate) {
179
- throw new DingtalkDwsError("INVALID_ARGUMENT", "Zero or multiple candidates; confirm one candidate_ref before binding");
197
+ throw new DingtalkDwsError("INVALID_ARGUMENT", `Multiple matches for ${JSON.stringify(input.query)}; pass candidate_ref after the user picks one. Do not guess.`);
180
198
  }
181
199
  const route = input.route ?? (candidate.type === "group" ? "notify_group" : "notify_user");
182
200
  if (routeType(route) !== candidate.type) {
183
201
  throw new DingtalkDwsError("INVALID_ARGUMENT", "candidate type does not match route");
184
202
  }
185
- writeTarget(this.config, route, candidate.target_id);
203
+ const displayName = candidate.name.trim();
204
+ if (!displayName) {
205
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "candidate name is required to bind a transparent route");
206
+ }
207
+ writeTarget(this.config, route, { id: candidate.target_id, name: displayName });
186
208
  return {
187
209
  route,
188
210
  type: candidate.type,
189
- name: candidate.name,
211
+ name: displayName,
212
+ id: candidate.target_id,
190
213
  candidate_ref: candidateRef(candidate),
191
214
  };
192
215
  }
@@ -198,6 +221,7 @@ export class DingtalkDwsService {
198
221
  validateSendContent(input, this.config);
199
222
  const title = input.title.trim();
200
223
  const content = input.content.trim();
224
+ const target = describeRouteBinding(this.config, input.route);
201
225
  if (input.dedupe_key) {
202
226
  const key = sha256(`${input.route}\n${input.dedupe_key}`);
203
227
  const claim = this.store.claim(key, Date.now(), this.config.suppressionWindowSeconds);
@@ -205,6 +229,7 @@ export class DingtalkDwsService {
205
229
  return {
206
230
  status: "suppressed",
207
231
  route: input.route,
232
+ target: { name: target.name, id: target.id },
208
233
  message_id: claim.messageId ?? null,
209
234
  deduplicated: true,
210
235
  };
@@ -222,6 +247,7 @@ export class DingtalkDwsService {
222
247
  return {
223
248
  status: "sent",
224
249
  route: input.route,
250
+ target: { name: target.name, id: target.id },
225
251
  message_id: result.messageId ?? null,
226
252
  deduplicated: false,
227
253
  sent_at: new Date().toISOString(),
@@ -240,6 +266,7 @@ export class DingtalkDwsService {
240
266
  return {
241
267
  status: "sent",
242
268
  route: input.route,
269
+ target: { name: target.name, id: target.id },
243
270
  message_id: result.messageId ?? null,
244
271
  deduplicated: false,
245
272
  sent_at: new Date().toISOString(),
@@ -16,7 +16,7 @@ export function buildInstructions(surface) {
16
16
  const lines = [
17
17
  "DingTalk robot notify + dws readiness (Agent surface of the dws ecosystem). Office stays on local dws.",
18
18
  "Office via local dws: before following upstream Skill recipes, read this server's MCP Resources overlays when they apply.",
19
- "Constraint: recurring/host patrol must not use session loops or repeated dingtalk_send — Agent derives host/cron/probe from conversation, calls dingtalk_patrol_setup once (stages bundle paths + delivery credentials), copies to host via host-execution or SSH, runs install.sh on the host, then stops. MCP env holds credentials only. Do not download Node in-session.",
19
+ "Constraint: host Job Runtime (RFC-0008) must not use session loops or repeated dingtalk_send — Agent calls dingtalk_patrol_setup once, follows copy_plan, then stops. Do not download Node in-session. dingtalk_send is one-shot on this machine only. MCP env holds credentials only.",
20
20
  "Call dingtalk_doctor first; connected ≠ ready.",
21
21
  ];
22
22
  if (surface.adapter === null) {
@@ -26,7 +26,7 @@ export function buildInstructions(surface) {
26
26
  lines.push("Webhook tools: doctor + send (notify_group only).");
27
27
  }
28
28
  else {
29
- lines.push("Managed tools: search → bind → send. Bind targets are for this server only.");
29
+ lines.push("Managed tools: search → bind → send. Bind targets are for this server only. Empty search (next_action=ask_user): tell the user and stop — do not retry with a guessed name or bind a similar person.");
30
30
  }
31
31
  lines.push("Never put credentials or secrets in message content.");
32
32
  if (surface.authTools) {
@@ -58,7 +58,7 @@ export function createToolDefinitions(surface) {
58
58
  if (surface.directoryTools) {
59
59
  tools.push({
60
60
  name: "dingtalk_search_targets",
61
- description: "Find groups/users by display name; returns opaque candidate_ref for bind.",
61
+ description: "Find groups/users by the name the user gave. Returns candidate_ref for bind. If candidates is empty, next_action is ask_user: report that to the user and stop. Do not invent another query or bind a similarly named person.",
62
62
  inputSchema: {
63
63
  type: "object",
64
64
  properties: {
@@ -72,7 +72,7 @@ export function createToolDefinitions(surface) {
72
72
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
73
73
  }, {
74
74
  name: "dingtalk_configure_route_target",
75
- description: "Bind one search result to notify_group or notify_user for later send.",
75
+ description: "Bind one search result to notify_group or notify_user. Use candidate_ref from search. If search was empty, do not call this with a different guessed query.",
76
76
  inputSchema: {
77
77
  type: "object",
78
78
  properties: {
@@ -90,7 +90,7 @@ export function createToolDefinitions(surface) {
90
90
  if (surface.listRoutes) {
91
91
  tools.push({
92
92
  name: "dingtalk_list_routes",
93
- description: "List whether notify_group / notify_user are bound.",
93
+ description: "List notify_group / notify_user bindings with target name and id so you can verify the destination before send.",
94
94
  inputSchema: emptySchema,
95
95
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
96
96
  });
@@ -119,7 +119,7 @@ export function createToolDefinitions(surface) {
119
119
  // Always available: stage host patrol materials (Agent transfers + remote install).
120
120
  tools.push({
121
121
  name: "dingtalk_patrol_setup",
122
- description: "Stage offline patrol materials only (patrol.yaml, env.sh with local delivery credentials, probe). Does not SSH. Agent fills name/cron/probe from conversation; prefer bound notify target (omit route). Then copy bundle_root + staging_dir to the host (host-execution or SSH) and run install.sh there. Call once for staging, finish install, then stop. Never returns secrets.",
122
+ description: "Stage host Job Runtime materials only. Does not SSH. Prefer intent=always|long_monitor|stop_on_critical|on_event. on_event needs watch_path (file/dir on the host; fs.watch, not cron). Follow copy_plan. Call once, then stop. Not dingtalk_send. Never returns secrets.",
123
123
  inputSchema: {
124
124
  type: "object",
125
125
  properties: {
@@ -128,6 +128,36 @@ export function createToolDefinitions(surface) {
128
128
  route: { type: "string", enum: ["notify_group", "notify_user"] },
129
129
  probe_command: { type: "string" },
130
130
  probe_script: { type: "string" },
131
+ intent: {
132
+ type: "string",
133
+ enum: ["always", "long_monitor", "stop_on_critical", "on_event"],
134
+ description: "Recipe alias. on_event requires watch_path (EventTrigger). Explicit deliver/termination override this.",
135
+ },
136
+ watch_path: {
137
+ type: "string",
138
+ description: "Host file or directory to watch when intent=on_event. Not a cron interval.",
139
+ },
140
+ deliver: {
141
+ type: "object",
142
+ additionalProperties: false,
143
+ properties: {
144
+ policy: { type: "string", enum: ["always", "on_status_change", "only_statuses"] },
145
+ statuses: {
146
+ type: "array",
147
+ items: { type: "string", enum: ["ok", "warning", "critical", "unknown"] },
148
+ },
149
+ },
150
+ },
151
+ termination: {
152
+ type: "object",
153
+ additionalProperties: false,
154
+ properties: {
155
+ policy: { type: "string", enum: ["none", "status_match", "manual"] },
156
+ status: { type: "string", enum: ["ok", "warning", "critical", "unknown"] },
157
+ on: { type: "string", enum: ["first", "until"] },
158
+ terminate_on_delivery_failure: { type: "boolean" },
159
+ },
160
+ },
131
161
  dry_run: { type: "boolean", description: "Allow missing bind/bundle (preview/tests); still writes staging" },
132
162
  },
133
163
  required: ["name"],
@@ -147,6 +177,70 @@ function requireNonEmptyString(input, key) {
147
177
  }
148
178
  return value.trim();
149
179
  }
180
+ function asObject(value, key) {
181
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
182
+ throw new DingtalkDwsError("INVALID_ARGUMENT", `${key} must be an object`);
183
+ }
184
+ return value;
185
+ }
186
+ function parseSetupIntent(value) {
187
+ if (value === undefined)
188
+ return undefined;
189
+ if (value === "always" || value === "long_monitor" || value === "stop_on_critical" || value === "on_event") {
190
+ return value;
191
+ }
192
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "intent must be always | long_monitor | stop_on_critical | on_event");
193
+ }
194
+ function parseSetupDeliver(value) {
195
+ if (value === undefined)
196
+ return undefined;
197
+ const rec = asObject(value, "deliver");
198
+ const out = {};
199
+ if (rec.policy !== undefined) {
200
+ if (rec.policy !== "always" && rec.policy !== "on_status_change" && rec.policy !== "only_statuses") {
201
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "deliver.policy must be always | on_status_change | only_statuses");
202
+ }
203
+ out.policy = rec.policy;
204
+ }
205
+ if (rec.statuses !== undefined) {
206
+ if (!Array.isArray(rec.statuses) || rec.statuses.some((s) => typeof s !== "string")) {
207
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "deliver.statuses must be a string array");
208
+ }
209
+ out.statuses = rec.statuses;
210
+ }
211
+ return out;
212
+ }
213
+ function parseSetupTermination(value) {
214
+ if (value === undefined)
215
+ return undefined;
216
+ const rec = asObject(value, "termination");
217
+ const out = {};
218
+ if (rec.policy !== undefined) {
219
+ if (rec.policy !== "none" && rec.policy !== "status_match" && rec.policy !== "manual") {
220
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.policy must be none | status_match | manual");
221
+ }
222
+ out.policy = rec.policy;
223
+ }
224
+ if (rec.status !== undefined) {
225
+ if (typeof rec.status !== "string") {
226
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.status must be a string");
227
+ }
228
+ out.status = rec.status;
229
+ }
230
+ if (rec.on !== undefined) {
231
+ if (rec.on !== "first" && rec.on !== "until") {
232
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.on must be first or until");
233
+ }
234
+ out.on = rec.on;
235
+ }
236
+ if (rec.terminate_on_delivery_failure !== undefined) {
237
+ if (typeof rec.terminate_on_delivery_failure !== "boolean") {
238
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.terminate_on_delivery_failure must be a boolean");
239
+ }
240
+ out.terminate_on_delivery_failure = rec.terminate_on_delivery_failure;
241
+ }
242
+ return out;
243
+ }
150
244
  function optionalPositiveInt(value, key) {
151
245
  if (value === undefined)
152
246
  return undefined;
@@ -259,6 +353,10 @@ export async function callTool(service, name, args, surface) {
259
353
  : parseRouteName(input.route, ["notify_group", "notify_user"]),
260
354
  probe_command: optionalString("probe_command"),
261
355
  probe_script: optionalString("probe_script"),
356
+ intent: parseSetupIntent(input.intent),
357
+ watch_path: optionalString("watch_path"),
358
+ deliver: parseSetupDeliver(input.deliver),
359
+ termination: parseSetupTermination(input.termination),
262
360
  dry_run: optionalBool("dry_run"),
263
361
  });
264
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dingtalk-dws-mcp",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "DingTalk robot notification MCP with dws readiness handshake",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -18,14 +18,18 @@
18
18
  "dingtalk-dws-mcp": "dist/src/index.js"
19
19
  },
20
20
  "scripts": {
21
- "build": "tsc -p tsconfig.json",
21
+ "sync:delivery": "node scripts/sync-notify-delivery.mjs",
22
+ "build": "npm run sync:delivery && tsc -p tsconfig.json",
22
23
  "prepack": "npm run build",
23
- "prepublishOnly": "npm run build && npm test",
24
+ "prepublishOnly": "npm run build && npm run test:mcp",
24
25
  "start": "node dist/src/index.js",
25
26
  "pack:deploy": "node scripts/pack-deploy.mjs",
26
- "test": "npm run build && vitest run",
27
+ "test:mcp": "npm run build && vitest run",
28
+ "test:lib": "npm --prefix lib/notify-delivery test",
29
+ "test:host-patrol": "npm --prefix host-patrol test",
30
+ "test": "npm run test:mcp && npm run test:lib && npm run test:host-patrol",
27
31
  "test:watch": "vitest",
28
- "typecheck": "tsc -p tsconfig.json --noEmit"
32
+ "typecheck": "npm run sync:delivery && tsc -p tsconfig.json --noEmit"
29
33
  },
30
34
  "keywords": [
31
35
  "mcp",
@@ -1,213 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import { DEFAULT_PATROL_MESSAGE_BODY, DEFAULT_PATROL_MESSAGE_TITLE, } from "../notify-delivery/index.js";
5
- import { DingtalkDwsError } from "../errors.js";
6
- import { isRouteConfigured, readTargets } from "../notify/bind.js";
7
- import { mcpPackageRoot, readPackageVersion } from "../version.js";
8
- const DEFAULT_CRON = "*/15 * * * *";
9
- const DEFAULT_PROBE = `#!/usr/bin/env bash
10
- set -euo pipefail
11
- printf '%s\\n' '{"status":"ok","summary":"【进度】示例探针正常\\n【监控】请替换为真实检查","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}'
12
- `;
13
- export function resolveBundleRoot(env = process.env) {
14
- const override = env.DINGTALK_PATROL_BUNDLE_ROOT?.trim();
15
- if (override)
16
- return path.resolve(override);
17
- return path.join(mcpPackageRoot(), "deploy", "linux-x64");
18
- }
19
- export function readBundleInfo(env = process.env) {
20
- const bundle_root = resolveBundleRoot(env);
21
- const manifestPath = path.join(bundle_root, "manifest.json");
22
- const ready = fs.existsSync(manifestPath) && fs.existsSync(path.join(bundle_root, "runtime", "patrol"));
23
- if (!ready) {
24
- return {
25
- available: false,
26
- bundle_root,
27
- fingerprint: null,
28
- server_version: null,
29
- includes_node: false,
30
- includes_dws: false,
31
- message: `Patrol bundle missing at ${bundle_root}. Run npm run pack:deploy.`,
32
- };
33
- }
34
- try {
35
- const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
36
- return {
37
- available: true,
38
- bundle_root,
39
- fingerprint: typeof m.fingerprint === "string" ? m.fingerprint : null,
40
- server_version: typeof m.server_version === "string" ? m.server_version : null,
41
- includes_node: Boolean(m.includes_node),
42
- includes_dws: Boolean(m.includes_dws),
43
- message: "bundle ready",
44
- };
45
- }
46
- catch {
47
- return {
48
- available: false,
49
- bundle_root,
50
- fingerprint: null,
51
- server_version: null,
52
- includes_node: false,
53
- includes_dws: false,
54
- message: `Invalid manifest at ${manifestPath}`,
55
- };
56
- }
57
- }
58
- function shellQuote(value) {
59
- return `'${value.replace(/'/g, `'\\''`)}'`;
60
- }
61
- function writeLf(filePath, content, mode) {
62
- const body = content.replace(/\r\n/g, "\n");
63
- if (mode === undefined)
64
- fs.writeFileSync(filePath, body, "utf8");
65
- else
66
- fs.writeFileSync(filePath, body, { mode });
67
- }
68
- /** Sync local notify credentials (already verified by doctor) into target env.sh. Not user OAuth.
69
- * Never copy laptop DINGTALK_DWS_COMMAND / config.dwsCommand — that path is Agent-host local
70
- * (Windows .exe or Linux absolute) and is wrong on the patrol target. Target resolves `dws` via
71
- * install runtime PATH (bundled linux ELF) or system dws. Optional target-only override:
72
- * DINGTALK_PATROL_HOST_DWS (path that exists on the target machine).
73
- */
74
- function writeEnvFile(filePath, config, env) {
75
- const lines = ["# Generated by dingtalk_patrol_setup — delivery credentials for target host"];
76
- const put = (key, value) => {
77
- if (value)
78
- lines.push(`export ${key}=${shellQuote(value)}`);
79
- };
80
- put("DINGTALK_CLIENT_ID", config.clientId ?? env.DINGTALK_CLIENT_ID);
81
- put("DINGTALK_CLIENT_SECRET", config.clientSecret ?? env.DINGTALK_CLIENT_SECRET);
82
- put("DINGTALK_ROBOT_CODE", config.robotCode ?? env.DINGTALK_ROBOT_CODE);
83
- put("DINGTALK_WEBHOOK_URL", config.webhookUrl ?? env.DINGTALK_WEBHOOK_URL);
84
- put("DINGTALK_WEBHOOK_SECRET", config.webhookSecret ?? env.DINGTALK_WEBHOOK_SECRET);
85
- const hostDws = env.DINGTALK_PATROL_HOST_DWS?.trim();
86
- if (hostDws)
87
- put("DINGTALK_DWS_COMMAND", hostDws);
88
- writeLf(filePath, `${lines.join("\n")}\n`, 0o600);
89
- }
90
- function pickRoute(config, preferred) {
91
- if (config.adapter === "webhook")
92
- return "notify_group";
93
- if (preferred) {
94
- if (!isRouteConfigured(config, preferred)) {
95
- throw new DingtalkDwsError("INVALID_ARGUMENT", `Route ${preferred} is not bound`);
96
- }
97
- return preferred;
98
- }
99
- if (isRouteConfigured(config, "notify_group"))
100
- return "notify_group";
101
- if (isRouteConfigured(config, "notify_user"))
102
- return "notify_user";
103
- throw new DingtalkDwsError("INVALID_ARGUMENT", "Bind a notify target first (or use webhook)");
104
- }
105
- function buildYaml(options) {
106
- const delivery = [` type: ${options.deliveryType}`];
107
- if (options.target)
108
- delivery.push(` ${options.target.field}: ${JSON.stringify(options.target.id)}`);
109
- const body = DEFAULT_PATROL_MESSAGE_BODY.split("\n").map((l) => ` ${l}`).join("\n");
110
- return `name: ${options.name}
111
- cron: ${JSON.stringify(options.cron)}
112
- probe:
113
- exec:
114
- command: ${JSON.stringify(options.probeCommand)}
115
- timeout_sec: 60
116
- delivery:
117
- ${delivery.join("\n")}
118
- template:
119
- title: ${JSON.stringify(DEFAULT_PATROL_MESSAGE_TITLE)}
120
- body: |
121
- ${body}
122
- `;
123
- }
124
- /**
125
- * Stage offline patrol materials only. Does not SSH/scp.
126
- * Agent copies bundle_root + staging_dir to the host (host-execution or SSH), then runs install.sh there.
127
- */
128
- export async function runPatrolSetup(config, input, env = process.env) {
129
- const name = input.name.trim();
130
- if (!name || name.includes("/") || name.includes("\\") || name.includes("..")) {
131
- throw new DingtalkDwsError("INVALID_ARGUMENT", "name must be a single path segment");
132
- }
133
- if (!config.adapter) {
134
- throw new DingtalkDwsError("INVALID_ARGUMENT", "Set DINGTALK_CLIENT_ID/SECRET or DINGTALK_WEBHOOK_URL in MCP env");
135
- }
136
- const cron = input.cron?.trim() || DEFAULT_CRON;
137
- const bundle = readBundleInfo(env);
138
- if (!bundle.available && !input.dry_run) {
139
- throw new DingtalkDwsError("ADAPTER_UNAVAILABLE", bundle.message);
140
- }
141
- const route = pickRoute(config, input.route);
142
- const deliveryType = config.adapter;
143
- let target;
144
- if (deliveryType === "dws") {
145
- if (!isRouteConfigured(config, route) && !input.dry_run) {
146
- throw new DingtalkDwsError("INVALID_ARGUMENT", `Bind ${route} before setup`);
147
- }
148
- const id = readTargets(config)[route];
149
- if (id) {
150
- target = route === "notify_group"
151
- ? { field: "open_conversation_id", id }
152
- : { field: "user_id", id };
153
- }
154
- }
155
- const stagingRoot = path.join(env.DINGTALK_PATROL_STAGING_DIR?.trim()
156
- ? path.resolve(env.DINGTALK_PATROL_STAGING_DIR)
157
- : path.join(os.tmpdir(), "dingtalk-patrol-staging"), name);
158
- fs.mkdirSync(path.join(stagingRoot, "probes"), { recursive: true, mode: 0o700 });
159
- const probeCommand = input.probe_command?.trim() || "./probes/example.sh";
160
- const probeFile = path.join(stagingRoot, "probes", path.basename(probeCommand) === probeCommand ? "example.sh" : path.basename(probeCommand));
161
- const script = input.probe_script ?? DEFAULT_PROBE;
162
- writeLf(probeFile, script.endsWith("\n") ? script : `${script}\n`, 0o755);
163
- const yamlPath = path.join(stagingRoot, "patrol.yaml");
164
- const envPath = path.join(stagingRoot, "env.sh");
165
- writeLf(yamlPath, buildYaml({
166
- name,
167
- cron,
168
- probeCommand: probeCommand.includes("/") || probeCommand.includes("\\")
169
- ? probeCommand
170
- : `./probes/${path.basename(probeFile)}`,
171
- deliveryType,
172
- target,
173
- }));
174
- writeEnvFile(envPath, config, env);
175
- const response = {
176
- ok: true,
177
- name,
178
- cron,
179
- route,
180
- next_action: "install_on_host",
181
- staging_dir: stagingRoot,
182
- bundle_root: bundle.bundle_root,
183
- bundle_fingerprint: bundle.fingerprint,
184
- includes_node: bundle.includes_node,
185
- includes_dws: bundle.includes_dws,
186
- install_hint: "Copy bundle_root (runtime+install.sh) and staging_dir to the host, then run: install.sh <staging>",
187
- server_version: readPackageVersion(),
188
- paths: { yaml: yamlPath, env: envPath, probe: probeFile },
189
- };
190
- if (deliveryType === "dws" && bundle.available && !bundle.includes_dws) {
191
- response.warning =
192
- "bundle includes_dws=false: target needs system dws on PATH, or re-pack with linux ELF (DINGTALK_PATROL_DWS_PATH)";
193
- }
194
- const serialized = JSON.stringify(response);
195
- for (const secret of [config.clientSecret, config.webhookSecret, env.DINGTALK_CLIENT_SECRET, env.DINGTALK_WEBHOOK_SECRET]) {
196
- if (secret && serialized.includes(secret)) {
197
- throw new DingtalkDwsError("INTERNAL_ERROR", "refusing to return secrets");
198
- }
199
- }
200
- return response;
201
- }
202
- export function bundleFingerprintForDoctor(env = process.env) {
203
- const info = readBundleInfo(env);
204
- return {
205
- available: info.available,
206
- fingerprint: info.fingerprint,
207
- server_version: info.server_version ?? readPackageVersion(),
208
- includes_node: info.includes_node,
209
- includes_dws: info.includes_dws,
210
- path: info.bundle_root,
211
- message: info.message,
212
- };
213
- }