dingtalk-dws-mcp 1.0.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.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +21 -0
  3. package/dist/src/config.d.ts +5 -0
  4. package/dist/src/config.js +99 -0
  5. package/dist/src/delivery/webhook.d.ts +7 -0
  6. package/dist/src/delivery/webhook.js +20 -0
  7. package/dist/src/directory/search.d.ts +4 -0
  8. package/dist/src/directory/search.js +78 -0
  9. package/dist/src/errors.d.ts +6 -0
  10. package/dist/src/errors.js +22 -0
  11. package/dist/src/handshake/instructions.d.ts +1 -0
  12. package/dist/src/handshake/instructions.js +9 -0
  13. package/dist/src/index.d.ts +2 -0
  14. package/dist/src/index.js +8 -0
  15. package/dist/src/internal/dws-auth-command.d.ts +34 -0
  16. package/dist/src/internal/dws-auth-command.js +98 -0
  17. package/dist/src/internal/dws-robot-command.d.ts +4 -0
  18. package/dist/src/internal/dws-robot-command.js +27 -0
  19. package/dist/src/internal/exec.d.ts +21 -0
  20. package/dist/src/internal/exec.js +59 -0
  21. package/dist/src/notify/bind.d.ts +7 -0
  22. package/dist/src/notify/bind.js +44 -0
  23. package/dist/src/notify/content.d.ts +2 -0
  24. package/dist/src/notify/content.js +24 -0
  25. package/dist/src/notify/idempotency-store.d.ts +16 -0
  26. package/dist/src/notify/idempotency-store.js +57 -0
  27. package/dist/src/patrol/setup.d.ts +27 -0
  28. package/dist/src/patrol/setup.js +213 -0
  29. package/dist/src/prompts.d.ts +21 -0
  30. package/dist/src/prompts.js +37 -0
  31. package/dist/src/server.d.ts +44 -0
  32. package/dist/src/server.js +126 -0
  33. package/dist/src/service.d.ts +33 -0
  34. package/dist/src/service.js +276 -0
  35. package/dist/src/tools/registry.d.ts +49 -0
  36. package/dist/src/tools/registry.js +275 -0
  37. package/dist/src/types.d.ts +40 -0
  38. package/dist/src/types.js +1 -0
  39. package/dist/src/version.d.ts +2 -0
  40. package/dist/src/version.js +30 -0
  41. package/package.json +44 -0
@@ -0,0 +1,126 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
+ import { loadConfig } from "./config.js";
5
+ import { IdempotencyStore } from "./notify/idempotency-store.js";
6
+ import { getPromptMessages, listPromptDefinitions } from "./prompts.js";
7
+ import { DingtalkDwsService } from "./service.js";
8
+ import { buildInstructions, callTool, createToolDefinitions, resolveToolSurface } from "./tools/registry.js";
9
+ import { readPackageVersion } from "./version.js";
10
+ export const MCP_SERVER_NAME = "dingtalk-dws";
11
+ export const MCP_SERVER_VERSION = readPackageVersion();
12
+ export function createMcpServer(service, store) {
13
+ let resolvedStore = store;
14
+ let resolvedService = service;
15
+ let adapter = service?.adapterKind() ?? null;
16
+ if (!resolvedService) {
17
+ const config = loadConfig();
18
+ adapter = config.adapter;
19
+ resolvedStore = resolvedStore ?? new IdempotencyStore(config.idempotencyPath);
20
+ resolvedService = new DingtalkDwsService(config, resolvedStore);
21
+ }
22
+ if (!resolvedStore)
23
+ throw new Error("store required");
24
+ const surface = resolveToolSurface(adapter);
25
+ const server = new Server({ name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION }, { capabilities: { tools: {}, prompts: {} }, instructions: buildInstructions(surface) });
26
+ const tools = createToolDefinitions(surface);
27
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...tools] }));
28
+ server.setRequestHandler(CallToolRequestSchema, async (request) => (callTool(resolvedService, request.params.name, request.params.arguments ?? {}, surface)));
29
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
30
+ prompts: listPromptDefinitions(surface),
31
+ }));
32
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
33
+ try {
34
+ return getPromptMessages(request.params.name);
35
+ }
36
+ catch {
37
+ throw new Error(`Unknown prompt: ${request.params.name}`);
38
+ }
39
+ });
40
+ return { server, service: resolvedService, store: resolvedStore };
41
+ }
42
+ export async function runStdioServer() {
43
+ const { server, store } = createMcpServer();
44
+ const transport = new StdioServerTransport();
45
+ let storeClosed = false;
46
+ let connected = false;
47
+ let connectionSettled = false;
48
+ let shutdownPromise;
49
+ let resolveStopped;
50
+ let resolveConnectionSettled;
51
+ const stopped = new Promise((resolve) => {
52
+ resolveStopped = resolve;
53
+ });
54
+ const connectionReady = new Promise((resolve) => {
55
+ resolveConnectionSettled = resolve;
56
+ });
57
+ const markConnectionSettled = () => {
58
+ if (!connectionSettled) {
59
+ connectionSettled = true;
60
+ resolveConnectionSettled?.();
61
+ }
62
+ };
63
+ const closeStore = () => {
64
+ if (!storeClosed) {
65
+ store.close();
66
+ storeClosed = true;
67
+ }
68
+ };
69
+ const shutdown = (closeServer = true) => {
70
+ if (!shutdownPromise) {
71
+ shutdownPromise = (async () => {
72
+ try {
73
+ await connectionReady;
74
+ if (closeServer && connected) {
75
+ await server.close();
76
+ }
77
+ }
78
+ finally {
79
+ closeStore();
80
+ resolveStopped?.();
81
+ }
82
+ })();
83
+ }
84
+ return shutdownPromise;
85
+ };
86
+ const onInputEnd = () => {
87
+ void shutdown();
88
+ };
89
+ const onSignal = () => {
90
+ process.exitCode = 0;
91
+ void shutdown();
92
+ };
93
+ const onExit = () => {
94
+ closeStore();
95
+ };
96
+ transport.onclose = () => {
97
+ void shutdown(false);
98
+ };
99
+ process.stdin.once("end", onInputEnd);
100
+ process.stdin.once("close", onInputEnd);
101
+ process.once("SIGINT", onSignal);
102
+ process.once("SIGTERM", onSignal);
103
+ process.once("exit", onExit);
104
+ try {
105
+ process.stdin.resume();
106
+ await server.connect(transport);
107
+ connected = true;
108
+ markConnectionSettled();
109
+ await stopped;
110
+ await shutdownPromise;
111
+ }
112
+ catch (error) {
113
+ markConnectionSettled();
114
+ await shutdown(false);
115
+ throw error;
116
+ }
117
+ finally {
118
+ markConnectionSettled();
119
+ process.stdin.off("end", onInputEnd);
120
+ process.stdin.off("close", onInputEnd);
121
+ process.off("SIGINT", onSignal);
122
+ process.off("SIGTERM", onSignal);
123
+ process.off("exit", onExit);
124
+ closeStore();
125
+ }
126
+ }
@@ -0,0 +1,33 @@
1
+ import type { CommandRunner } from "./internal/exec.js";
2
+ import type { AdapterKind, AppConfig, RouteName, RouteType, SendInput } from "./types.js";
3
+ import { IdempotencyStore } from "./notify/idempotency-store.js";
4
+ import { type PatrolSetupInput } from "./patrol/setup.js";
5
+ export declare class DingtalkDwsService {
6
+ private readonly config;
7
+ private readonly store;
8
+ private readonly runner?;
9
+ private readonly env;
10
+ private readonly fetchImpl;
11
+ constructor(config: AppConfig, store: IdempotencyStore, runner?: CommandRunner | undefined, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch);
12
+ doctor(): Promise<Record<string, unknown>>;
13
+ patrolSetup(input: PatrolSetupInput): Promise<Record<string, unknown>>;
14
+ authStatus(): Promise<Record<string, unknown>>;
15
+ authLogin(): Promise<Record<string, unknown>>;
16
+ listRoutes(): Record<string, unknown>;
17
+ /** Adapter used to shape the public MCP tool surface. */
18
+ adapterKind(): AdapterKind | null;
19
+ searchTargets(input: {
20
+ query: string;
21
+ type?: "all" | RouteType;
22
+ limit?: number;
23
+ }): Promise<Record<string, unknown>>;
24
+ configureRouteTarget(input: {
25
+ query: string;
26
+ route?: RouteName;
27
+ type?: RouteType;
28
+ candidate_ref?: string;
29
+ }): Promise<Record<string, unknown>>;
30
+ send(input: SendInput): Promise<Record<string, unknown>>;
31
+ private deliver;
32
+ }
33
+ export type { AppConfig };
@@ -0,0 +1,276 @@
1
+ import { assertAdapterConfigured, ensureStateDir } from "./config.js";
2
+ import { searchTargets } from "./directory/search.js";
3
+ import { sendWebhook, validateWebhookConfig } from "./delivery/webhook.js";
4
+ import { DingtalkDwsError } from "./errors.js";
5
+ import { dwsAuthLogin, dwsAuthStatus, probeDws } from "./internal/dws-auth-command.js";
6
+ import { sendRobotMessage } from "./internal/dws-robot-command.js";
7
+ import { candidateRef, isRouteConfigured, resolveRouteTarget, routeType, writeTarget } from "./notify/bind.js";
8
+ import { validateSendContent } from "./notify/content.js";
9
+ import { sha256 } from "./notify/idempotency-store.js";
10
+ import { bundleFingerprintForDoctor, runPatrolSetup } from "./patrol/setup.js";
11
+ import { readPackageVersion } from "./version.js";
12
+ export class DingtalkDwsService {
13
+ config;
14
+ store;
15
+ runner;
16
+ env;
17
+ fetchImpl;
18
+ constructor(config, store, runner, env = process.env, fetchImpl = fetch) {
19
+ this.config = config;
20
+ this.store = store;
21
+ this.runner = runner;
22
+ this.env = env;
23
+ this.fetchImpl = fetchImpl;
24
+ ensureStateDir(config);
25
+ }
26
+ async doctor() {
27
+ const probe = await probeDws(this.config.dwsCommand, {
28
+ timeoutMs: this.config.timeoutMs,
29
+ maxOutputBytes: this.config.maxOutputBytes,
30
+ runner: this.runner,
31
+ env: this.env,
32
+ });
33
+ const auth = probe.installed
34
+ ? await dwsAuthStatus(this.config.dwsCommand, {
35
+ timeoutMs: this.config.timeoutMs,
36
+ maxOutputBytes: this.config.maxOutputBytes,
37
+ runner: this.runner,
38
+ env: this.env,
39
+ })
40
+ : { cli_available: false, authenticated: null, message: probe.message, next_action: "Install dws" };
41
+ const dwsReady = probe.installed && auth.authenticated === true;
42
+ let notifyReady = false;
43
+ let notifyMessage = this.config.adapterError ?? "Notify credentials are not configured";
44
+ 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
+ }
51
+ else if (this.config.adapter === "webhook") {
52
+ const validation = validateWebhookConfig(this.config);
53
+ notifyReady = validation.valid;
54
+ notifyMessage = validation.message;
55
+ }
56
+ let next_action = null;
57
+ if (this.config.adapterError)
58
+ next_action = this.config.adapterError;
59
+ else if (!this.config.adapter)
60
+ next_action = "Set DINGTALK_CLIENT_ID/SECRET or DINGTALK_WEBHOOK_URL";
61
+ else if (this.config.adapter === "dws" && !dwsReady && !probe.installed)
62
+ next_action = probe.message;
63
+ else if (this.config.adapter === "dws" && auth.authenticated !== true) {
64
+ next_action = this.config.authLoginMode === "manual"
65
+ ? "Run `dws auth login` in a local terminal, then call dingtalk_auth_status"
66
+ : "Call dingtalk_auth_login";
67
+ }
68
+ else if (!notifyReady && this.config.adapter === "dws") {
69
+ next_action = "Call dingtalk_configure_route_target to bind notify_group or notify_user";
70
+ }
71
+ else if (!notifyReady)
72
+ next_action = notifyMessage;
73
+ else if (!dwsReady && this.config.adapter === "webhook") {
74
+ // Notify works; office readiness is informational only (no auth tools in webhook mode).
75
+ next_action = null;
76
+ }
77
+ else if (!dwsReady)
78
+ next_action = auth.next_action ?? probe.message;
79
+ return {
80
+ server_version: readPackageVersion(),
81
+ patrol_bundle: bundleFingerprintForDoctor(this.env),
82
+ dws: {
83
+ installed: probe.installed,
84
+ version: probe.version ?? null,
85
+ authenticated: auth.authenticated,
86
+ ready: dwsReady,
87
+ message: dwsReady ? "dws is ready for office tasks via Skill/shell" : auth.message,
88
+ },
89
+ notify: {
90
+ 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
+ ready: notifyReady,
95
+ 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
+ },
102
+ },
103
+ next_action,
104
+ };
105
+ }
106
+ async patrolSetup(input) {
107
+ return runPatrolSetup(this.config, input, this.env);
108
+ }
109
+ async authStatus() {
110
+ const status = await dwsAuthStatus(this.config.dwsCommand, {
111
+ timeoutMs: this.config.timeoutMs,
112
+ maxOutputBytes: this.config.maxOutputBytes,
113
+ runner: this.runner,
114
+ env: this.env,
115
+ });
116
+ return { ...status };
117
+ }
118
+ async authLogin() {
119
+ return dwsAuthLogin(this.config.dwsCommand, this.config.authLoginMode, {
120
+ timeoutMs: this.config.timeoutMs,
121
+ maxOutputBytes: this.config.maxOutputBytes,
122
+ runner: this.runner,
123
+ env: this.env,
124
+ });
125
+ }
126
+ listRoutes() {
127
+ assertAdapterConfigured(this.config);
128
+ const names = this.config.adapter === "webhook"
129
+ ? ["notify_group"]
130
+ : ["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
+ return {
138
+ adapter: this.config.adapter,
139
+ routes,
140
+ };
141
+ }
142
+ /** Adapter used to shape the public MCP tool surface. */
143
+ adapterKind() {
144
+ return this.config.adapter;
145
+ }
146
+ async searchTargets(input) {
147
+ assertAdapterConfigured(this.config);
148
+ if (this.config.adapter === "webhook") {
149
+ throw new DingtalkDwsError("ADAPTER_UNSUPPORTED", "search_targets is not available for webhook adapter");
150
+ }
151
+ const types = !input.type || input.type === "all" ? ["group", "user"] : [input.type];
152
+ const candidates = [];
153
+ for (const type of types) {
154
+ candidates.push(...await searchTargets(this.config, type, input.query, input.limit ?? 10, this.runner, this.env));
155
+ }
156
+ return {
157
+ candidates: candidates.map((candidate) => ({
158
+ type: candidate.type,
159
+ name: candidate.name,
160
+ candidate_ref: candidateRef(candidate),
161
+ })),
162
+ };
163
+ }
164
+ async configureRouteTarget(input) {
165
+ assertAdapterConfigured(this.config);
166
+ if (this.config.adapter === "webhook") {
167
+ throw new DingtalkDwsError("ADAPTER_UNSUPPORTED", "configure_route_target is not available for webhook adapter");
168
+ }
169
+ const searchType = input.route ? routeType(input.route) : input.type ?? "all";
170
+ const types = searchType === "all" ? ["group", "user"] : [searchType];
171
+ const candidates = [];
172
+ for (const type of types) {
173
+ candidates.push(...await searchTargets(this.config, type, input.query, input.candidate_ref ? 20 : 2, this.runner, this.env));
174
+ }
175
+ const candidate = input.candidate_ref
176
+ ? candidates.find((item) => candidateRef(item) === input.candidate_ref)
177
+ : candidates.length === 1 ? candidates[0] : undefined;
178
+ if (!candidate) {
179
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "Zero or multiple candidates; confirm one candidate_ref before binding");
180
+ }
181
+ const route = input.route ?? (candidate.type === "group" ? "notify_group" : "notify_user");
182
+ if (routeType(route) !== candidate.type) {
183
+ throw new DingtalkDwsError("INVALID_ARGUMENT", "candidate type does not match route");
184
+ }
185
+ writeTarget(this.config, route, candidate.target_id);
186
+ return {
187
+ route,
188
+ type: candidate.type,
189
+ name: candidate.name,
190
+ candidate_ref: candidateRef(candidate),
191
+ };
192
+ }
193
+ async send(input) {
194
+ assertAdapterConfigured(this.config);
195
+ if (input.route !== "notify_group" && input.route !== "notify_user") {
196
+ throw new DingtalkDwsError("ROUTE_NOT_FOUND", "Unknown route");
197
+ }
198
+ validateSendContent(input, this.config);
199
+ const title = input.title.trim();
200
+ const content = input.content.trim();
201
+ if (input.dedupe_key) {
202
+ const key = sha256(`${input.route}\n${input.dedupe_key}`);
203
+ const claim = this.store.claim(key, Date.now(), this.config.suppressionWindowSeconds);
204
+ if (!claim.claimed) {
205
+ return {
206
+ status: "suppressed",
207
+ route: input.route,
208
+ message_id: claim.messageId ?? null,
209
+ deduplicated: true,
210
+ };
211
+ }
212
+ let recorded = false;
213
+ try {
214
+ const result = await this.deliver(input.route, title, content);
215
+ if (result.status !== "sent") {
216
+ this.store.mark(key, result.status);
217
+ recorded = true;
218
+ throw deliveryError(result);
219
+ }
220
+ this.store.mark(key, result.status, result.messageId);
221
+ recorded = true;
222
+ return {
223
+ status: "sent",
224
+ route: input.route,
225
+ message_id: result.messageId ?? null,
226
+ deduplicated: false,
227
+ sent_at: new Date().toISOString(),
228
+ };
229
+ }
230
+ catch (error) {
231
+ if (!recorded) {
232
+ this.store.mark(key, error instanceof DingtalkDwsError && error.code !== "DELIVERY_UNKNOWN" ? "failed" : "unknown");
233
+ }
234
+ throw error;
235
+ }
236
+ }
237
+ const result = await this.deliver(input.route, title, content);
238
+ if (result.status !== "sent")
239
+ throw deliveryError(result);
240
+ return {
241
+ status: "sent",
242
+ route: input.route,
243
+ message_id: result.messageId ?? null,
244
+ deduplicated: false,
245
+ sent_at: new Date().toISOString(),
246
+ };
247
+ }
248
+ async deliver(route, title, content) {
249
+ const targetId = resolveRouteTarget(this.config, route);
250
+ const request = {
251
+ routeType: routeType(route),
252
+ targetId,
253
+ title,
254
+ content,
255
+ };
256
+ if (this.config.adapter === "webhook") {
257
+ return sendWebhook(this.config, request, this.fetchImpl);
258
+ }
259
+ return sendRobotMessage(this.config, request, this.runner, this.env);
260
+ }
261
+ }
262
+ const DELIVERY_CODES = [
263
+ "ADAPTER_UNAVAILABLE",
264
+ "ADAPTER_UNAUTHORIZED",
265
+ "ADAPTER_REJECTED",
266
+ "ADAPTER_UNSUPPORTED",
267
+ "DELIVERY_FAILED",
268
+ "DELIVERY_UNKNOWN",
269
+ ];
270
+ function deliveryError(result) {
271
+ const code = DELIVERY_CODES.includes(result.errorCode)
272
+ ? result.errorCode
273
+ : result.status === "unknown" ? "DELIVERY_UNKNOWN" : "DELIVERY_FAILED";
274
+ const message = result.status === "failed" ? result.error : result.reason;
275
+ return new DingtalkDwsError(code, message);
276
+ }
@@ -0,0 +1,49 @@
1
+ import type { DingtalkDwsService } from "../service.js";
2
+ import type { AdapterKind } from "../types.js";
3
+ export type ToolSurface = {
4
+ adapter: AdapterKind | null;
5
+ authTools: boolean;
6
+ directoryTools: boolean;
7
+ listRoutes: boolean;
8
+ send: boolean;
9
+ };
10
+ export declare function resolveToolSurface(adapter: AdapterKind | null): ToolSurface;
11
+ export declare function buildInstructions(surface: ToolSurface): string;
12
+ type ToolDefinition = {
13
+ name: string;
14
+ description: string;
15
+ inputSchema: Record<string, unknown>;
16
+ annotations: {
17
+ readOnlyHint: boolean;
18
+ destructiveHint: boolean;
19
+ idempotentHint: boolean;
20
+ openWorldHint: boolean;
21
+ };
22
+ };
23
+ export declare function createToolDefinitions(surface: ToolSurface): ToolDefinition[];
24
+ export declare function callTool(service: DingtalkDwsService, name: string, args: unknown, surface?: ToolSurface): Promise<{
25
+ isError: boolean;
26
+ content: {
27
+ type: "text";
28
+ text: string;
29
+ }[];
30
+ structuredContent: Record<string, unknown>;
31
+ } | {
32
+ content: {
33
+ type: "text";
34
+ text: string;
35
+ }[];
36
+ structuredContent: Record<string, unknown>;
37
+ isError?: undefined;
38
+ } | {
39
+ isError: boolean;
40
+ content: {
41
+ type: "text";
42
+ text: string;
43
+ }[];
44
+ structuredContent: {
45
+ code: import("../errors.js").DingtalkDwsErrorCode;
46
+ message: string;
47
+ };
48
+ }>;
49
+ export {};