dingtalk-dws-mcp 1.0.1 → 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 +20 -8
- package/dist/src/notify/bind.d.ts +17 -2
- package/dist/src/notify/bind.js +55 -13
- package/dist/src/{patrol → patrol-setup}/setup.d.ts +23 -2
- package/dist/src/patrol-setup/setup.js +386 -0
- package/dist/src/prompts.js +3 -5
- package/dist/src/resources/guides.d.ts +24 -0
- package/dist/src/resources/guides.js +36 -0
- package/dist/src/server.js +7 -2
- package/dist/src/service.d.ts +1 -1
- package/dist/src/service.js +58 -31
- package/dist/src/tools/registry.js +105 -6
- package/guides/doc-search.md +37 -0
- package/package.json +10 -5
- package/dist/src/patrol/setup.js +0 -213
package/README.md
CHANGED
|
@@ -2,20 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
源码包。用户能力与接入见 [`mcp/dingtalk-dws-mcp/GUIDE.md`](../../../mcp/dingtalk-dws-mcp/GUIDE.md)。
|
|
4
4
|
|
|
5
|
-
Agent:`instructions` / tool `description` / `prompts
|
|
5
|
+
Agent:`instructions` / tool `description` / `prompts` / **Resources(`guides/*.md`)**。长期巡检:`dingtalk_patrol_setup` 备料 → 传装到主机 → 停。
|
|
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/` 混淆。
|
|
6
18
|
|
|
7
19
|
## 构建
|
|
8
20
|
|
|
9
21
|
```bash
|
|
10
|
-
cd packages/
|
|
11
|
-
|
|
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
|
-
`
|
|
16
|
-
|
|
17
|
-
`
|
|
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.
|
|
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
|
-
|
|
4
|
-
export declare function
|
|
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;
|
package/dist/src/notify/bind.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
12
|
-
return
|
|
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
|
-
|
|
33
|
+
return {};
|
|
16
34
|
}
|
|
17
|
-
return {};
|
|
18
35
|
}
|
|
19
|
-
export function
|
|
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
|
-
|
|
22
|
-
targets
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
+
}
|
package/dist/src/prompts.js
CHANGED
|
@@ -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
|
|
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:
|
|
8
|
-
3.
|
|
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
|
{
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type GuideMeta = {
|
|
2
|
+
slug: string;
|
|
3
|
+
uri: string;
|
|
4
|
+
title: string;
|
|
5
|
+
description: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function listMcpGuideResources(): Array<{
|
|
8
|
+
uri: string;
|
|
9
|
+
name: string;
|
|
10
|
+
title: string;
|
|
11
|
+
description: string;
|
|
12
|
+
mimeType: string;
|
|
13
|
+
annotations: {
|
|
14
|
+
audience: Array<"user" | "assistant">;
|
|
15
|
+
priority: number;
|
|
16
|
+
};
|
|
17
|
+
}>;
|
|
18
|
+
export declare function readMcpGuideResource(uri: string): {
|
|
19
|
+
contents: Array<{
|
|
20
|
+
uri: string;
|
|
21
|
+
mimeType: string;
|
|
22
|
+
text: string;
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { mcpPackageRoot } from "../version.js";
|
|
4
|
+
const GUIDES = [
|
|
5
|
+
{
|
|
6
|
+
slug: "doc-search",
|
|
7
|
+
uri: "dingtalk-dws://guides/doc-search",
|
|
8
|
+
title: "DingTalk cloud doc search",
|
|
9
|
+
description: "Agent overlay: drive search short path and stop-rules; prefer over upstream doc search recipes when they lag.",
|
|
10
|
+
},
|
|
11
|
+
];
|
|
12
|
+
function readMarkdown(slug) {
|
|
13
|
+
const filePath = path.join(mcpPackageRoot(), "guides", `${slug}.md`);
|
|
14
|
+
return fs.readFileSync(filePath, "utf8");
|
|
15
|
+
}
|
|
16
|
+
export function listMcpGuideResources() {
|
|
17
|
+
return GUIDES.map((g) => ({
|
|
18
|
+
uri: g.uri,
|
|
19
|
+
name: g.slug,
|
|
20
|
+
title: g.title,
|
|
21
|
+
description: g.description,
|
|
22
|
+
mimeType: "text/markdown",
|
|
23
|
+
annotations: { audience: ["assistant"], priority: 0.7 },
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
export function readMcpGuideResource(uri) {
|
|
27
|
+
const guide = GUIDES.find((g) => g.uri === uri);
|
|
28
|
+
if (!guide) {
|
|
29
|
+
const err = new Error(`Resource not found: ${uri}`);
|
|
30
|
+
err.code = -32002;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
contents: [{ uri, mimeType: "text/markdown", text: readMarkdown(guide.slug) }],
|
|
35
|
+
};
|
|
36
|
+
}
|