@wukongcrm/mcp-server 0.1.4 → 0.2.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/dist/oauth.js CHANGED
@@ -1,94 +1,8 @@
1
1
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
2
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
- import path from "node:path";
4
2
  import { AccessDeniedError, InvalidGrantError, InvalidScopeError, InvalidTargetError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
5
3
  import { CrmClient } from "./client.js";
4
+ import { FileOAuthState, RedisOAuthState } from "./oauth-state.js";
6
5
  import { CRM_READ_SCOPE, OAUTH_SCOPES, OFFLINE_ACCESS_SCOPE } from "./server.js";
7
- export class FileOAuthState {
8
- filePath;
9
- clients = new Map();
10
- revoked = new Map();
11
- ready;
12
- persistQueue = Promise.resolve();
13
- constructor(dataDirectory) {
14
- this.filePath = path.join(dataDirectory, "oauth-state.json");
15
- this.ready = this.load();
16
- }
17
- async getClient(clientId) {
18
- await this.ready;
19
- return this.clients.get(clientId);
20
- }
21
- async registerClient(client) {
22
- await this.ready;
23
- const now = Math.floor(Date.now() / 1000);
24
- const fullClient = {
25
- ...client,
26
- client_id: client.client_id ?? randomUUID(),
27
- client_id_issued_at: client.client_id_issued_at ?? now
28
- };
29
- this.clients.set(fullClient.client_id, fullClient);
30
- await this.persist();
31
- return fullClient;
32
- }
33
- async revoke(jti, expiresAt) {
34
- await this.ready;
35
- this.removeExpiredRevocations();
36
- this.revoked.set(jti, expiresAt);
37
- await this.persist();
38
- }
39
- async isRevoked(jti) {
40
- await this.ready;
41
- this.removeExpiredRevocations();
42
- return this.revoked.has(jti);
43
- }
44
- async load() {
45
- try {
46
- const parsed = JSON.parse(await readFile(this.filePath, "utf8"));
47
- for (const [clientId, client] of Object.entries(parsed.clients ?? {})) {
48
- if (client && client.client_id === clientId && Array.isArray(client.redirect_uris)) {
49
- this.clients.set(clientId, client);
50
- }
51
- }
52
- for (const [jti, expiresAt] of Object.entries(parsed.revoked ?? {})) {
53
- if (Number.isFinite(expiresAt)) {
54
- this.revoked.set(jti, expiresAt);
55
- }
56
- }
57
- this.removeExpiredRevocations();
58
- }
59
- catch (error) {
60
- if (error.code !== "ENOENT") {
61
- throw error;
62
- }
63
- }
64
- }
65
- removeExpiredRevocations() {
66
- const now = Math.floor(Date.now() / 1000);
67
- for (const [jti, expiresAt] of this.revoked) {
68
- if (expiresAt <= now) {
69
- this.revoked.delete(jti);
70
- }
71
- }
72
- }
73
- async persist() {
74
- const operation = this.persistQueue.catch(() => undefined).then(async () => {
75
- const directory = path.dirname(this.filePath);
76
- await mkdir(directory, { recursive: true });
77
- const temporaryPath = `${this.filePath}.${process.pid}.tmp`;
78
- const state = {
79
- clients: Object.fromEntries(this.clients),
80
- revoked: Object.fromEntries(this.revoked)
81
- };
82
- await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, {
83
- encoding: "utf8",
84
- mode: 0o600
85
- });
86
- await rename(temporaryPath, this.filePath);
87
- });
88
- this.persistQueue = operation;
89
- await operation;
90
- }
91
- }
92
6
  export class WukongOAuthProvider {
93
7
  clientsStore;
94
8
  resourceUrl;
@@ -102,8 +16,6 @@ export class WukongOAuthProvider {
102
16
  encryptionKey;
103
17
  accessTokenTtlSeconds;
104
18
  refreshTokenTtlSeconds;
105
- pendingAuthorizations = new Map();
106
- authorizationCodes = new Map();
107
19
  constructor(options) {
108
20
  if (options.secret.length < 32) {
109
21
  throw new Error("MCP_OAUTH_SECRET 至少需要 32 个字符。");
@@ -112,7 +24,7 @@ export class WukongOAuthProvider {
112
24
  this.crmBaseUrl = options.crmBaseUrl;
113
25
  this.fetchImpl = options.fetchImpl;
114
26
  this.crmBindingAuthorizeUrl = normalizeBindingEndpoint(options.crmBindingAuthorizeUrl, "CRM_BINDING_AUTHORIZE_URL");
115
- this.crmBindingExchangeUrl = normalizeBindingEndpoint(options.crmBindingExchangeUrl, "CRM_BINDING_EXCHANGE_URL");
27
+ this.crmBindingExchangeUrl = normalizeBindingEndpoint(options.crmBindingExchangeUrl, "CRM_BINDING_EXCHANGE_URL", { allowHttp: options.crmBindingExchangeAllowHttp });
116
28
  this.crmBindingCallbackUrl = normalizeBindingEndpoint(options.crmBindingCallbackUrl, "CRM_BINDING_CALLBACK_URL");
117
29
  if (!options.crmBindingClientId.trim()) {
118
30
  throw new Error("CRM_BINDING_CLIENT_ID 不能为空。");
@@ -125,21 +37,29 @@ export class WukongOAuthProvider {
125
37
  this.encryptionKey = createHash("sha256").update(options.secret, "utf8").digest();
126
38
  this.accessTokenTtlSeconds = options.accessTokenTtlSeconds ?? 60 * 60;
127
39
  this.refreshTokenTtlSeconds = options.refreshTokenTtlSeconds ?? 30 * 24 * 60 * 60;
128
- this.clientsStore = new FileOAuthState(options.dataDirectory);
40
+ this.clientsStore = options.stateStore ?? (options.redisUrl
41
+ ? new RedisOAuthState({ url: options.redisUrl, keyPrefix: options.redisKeyPrefix })
42
+ : new FileOAuthState(options.dataDirectory));
43
+ }
44
+ async initialize() {
45
+ await this.clientsStore.initialize();
46
+ }
47
+ async close() {
48
+ await this.clientsStore.close();
129
49
  }
130
50
  async authorize(client, params, res) {
131
- this.cleanupTransientState();
132
51
  const resource = normalizeResourceUrl(params.resource ?? this.resourceUrl);
133
52
  this.assertResource(resource);
134
53
  const scopes = normalizeScopes(params.scopes);
135
54
  const requestId = randomUUID();
136
- this.pendingAuthorizations.set(requestId, {
55
+ const pending = {
137
56
  client,
138
57
  params,
139
58
  resource,
140
59
  scopes,
141
60
  expiresAt: Date.now() + 5 * 60 * 1000
142
- });
61
+ };
62
+ await this.clientsStore.setPendingAuthorization(requestId, this.encryptTransientState("pending", serializePendingAuthorization(pending)), pending.expiresAt);
143
63
  const authorizationUrl = new URL(this.crmBindingAuthorizeUrl.href);
144
64
  authorizationUrl.searchParams.set("client_id", this.crmBindingClientId);
145
65
  authorizationUrl.searchParams.set("redirect_uri", this.crmBindingCallbackUrl.href);
@@ -149,8 +69,10 @@ export class WukongOAuthProvider {
149
69
  res.redirect(302, authorizationUrl.href);
150
70
  }
151
71
  async completeBindingAuthorization(requestId, bindingCode) {
152
- this.cleanupTransientState();
153
- const pending = this.pendingAuthorizations.get(requestId);
72
+ const pendingValue = await this.clientsStore.getPendingAuthorization(requestId);
73
+ const pending = pendingValue
74
+ ? deserializePendingAuthorization(this.decryptTransientState("pending", pendingValue))
75
+ : undefined;
154
76
  if (!pending) {
155
77
  throw new AccessDeniedError("授权请求已失效,请返回 ChatGPT 重新授权。");
156
78
  }
@@ -169,12 +91,13 @@ export class WukongOAuthProvider {
169
91
  throw new AccessDeniedError("72CRM 静默生成的 API Key 验证失败,请重新授权。");
170
92
  }
171
93
  const code = randomUUID();
172
- this.authorizationCodes.set(code, {
94
+ const codeData = {
173
95
  ...pending,
174
96
  expiresAt: Date.now() + 5 * 60 * 1000,
175
97
  apiKey: normalizedApiKey
176
- });
177
- this.pendingAuthorizations.delete(requestId);
98
+ };
99
+ await this.clientsStore.setAuthorizationCode(code, this.encryptTransientState("code", serializeAuthorizationCode(codeData)), codeData.expiresAt);
100
+ await this.clientsStore.deletePendingAuthorization(requestId);
178
101
  const target = new URL(pending.params.redirectUri);
179
102
  target.searchParams.set("code", code);
180
103
  if (pending.params.state) {
@@ -188,16 +111,22 @@ export class WukongOAuthProvider {
188
111
  const signature = createHmac("sha256", this.crmBindingClientSecret)
189
112
  .update(message, "utf8")
190
113
  .digest("base64url");
191
- const response = await (this.fetchImpl ?? fetch)(this.crmBindingExchangeUrl, {
192
- method: "POST",
193
- headers: {
194
- "Content-Type": "application/json",
195
- "X-MCP-Client-Id": this.crmBindingClientId,
196
- "X-MCP-Timestamp": timestamp,
197
- "X-MCP-Signature": signature
198
- },
199
- body: JSON.stringify({ code: bindingCode })
200
- });
114
+ let response;
115
+ try {
116
+ response = await (this.fetchImpl ?? fetch)(this.crmBindingExchangeUrl, {
117
+ method: "POST",
118
+ headers: {
119
+ "Content-Type": "application/json",
120
+ "X-MCP-Client-Id": this.crmBindingClientId,
121
+ "X-MCP-Timestamp": timestamp,
122
+ "X-MCP-Signature": signature
123
+ },
124
+ body: JSON.stringify({ code: bindingCode })
125
+ });
126
+ }
127
+ catch (error) {
128
+ throw new Error(`72CRM 绑定交换请求失败(${this.crmBindingExchangeUrl.origin}):${describeFetchError(error)}`, { cause: error });
129
+ }
201
130
  if (!response.ok) {
202
131
  throw new AccessDeniedError("无法从 72CRM 完成静默凭证交换。");
203
132
  }
@@ -209,19 +138,19 @@ export class WukongOAuthProvider {
209
138
  return apiKey;
210
139
  }
211
140
  async challengeForAuthorizationCode(client, authorizationCode) {
212
- const codeData = this.getAuthorizationCode(client, authorizationCode);
141
+ const codeData = await this.getAuthorizationCode(client, authorizationCode);
213
142
  return codeData.params.codeChallenge;
214
143
  }
215
144
  async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
216
- const codeData = this.getAuthorizationCode(client, authorizationCode);
145
+ const codeData = await this.getAuthorizationCode(client, authorizationCode);
217
146
  if (redirectUri && redirectUri !== codeData.params.redirectUri) {
218
147
  throw new InvalidGrantError("redirect_uri 与授权请求不一致。");
219
148
  }
220
149
  if (resource && normalizeResourceUrl(resource).href !== codeData.resource.href) {
221
150
  throw new InvalidTargetError("resource 与授权请求不一致。");
222
151
  }
223
- this.authorizationCodes.delete(authorizationCode);
224
- return this.issueTokens(codeData.apiKey, client.client_id, codeData.scopes, codeData.resource);
152
+ const consumedCodeData = await this.consumeAuthorizationCode(client, authorizationCode);
153
+ return this.issueTokens(consumedCodeData.apiKey, client.client_id, consumedCodeData.scopes, consumedCodeData.resource);
225
154
  }
226
155
  async exchangeRefreshToken(client, refreshToken, scopes, resource) {
227
156
  const payload = await this.decodeAndVerifyToken(refreshToken, "refresh");
@@ -264,14 +193,26 @@ export class WukongOAuthProvider {
264
193
  // RFC 7009 requires an invalid token to produce the same successful response.
265
194
  }
266
195
  }
267
- getAuthorizationCode(client, authorizationCode) {
268
- this.cleanupTransientState();
269
- const codeData = this.authorizationCodes.get(authorizationCode);
196
+ async getAuthorizationCode(client, authorizationCode) {
197
+ const value = await this.clientsStore.getAuthorizationCode(authorizationCode);
198
+ const codeData = value
199
+ ? deserializeAuthorizationCode(this.decryptTransientState("code", value))
200
+ : undefined;
270
201
  if (!codeData || codeData.client.client_id !== client.client_id) {
271
202
  throw new InvalidGrantError("授权码无效或已过期。");
272
203
  }
273
204
  return codeData;
274
205
  }
206
+ async consumeAuthorizationCode(client, authorizationCode) {
207
+ const value = await this.clientsStore.consumeAuthorizationCode(authorizationCode);
208
+ const codeData = value
209
+ ? deserializeAuthorizationCode(this.decryptTransientState("code", value))
210
+ : undefined;
211
+ if (!codeData || codeData.client.client_id !== client.client_id) {
212
+ throw new InvalidGrantError("授权码无效、已过期或已被使用。");
213
+ }
214
+ return codeData;
215
+ }
275
216
  issueTokens(apiKey, clientId, scopes, resource) {
276
217
  const now = Math.floor(Date.now() / 1000);
277
218
  const accessPayload = {
@@ -329,17 +270,7 @@ export class WukongOAuthProvider {
329
270
  return ["wkm1", iv.toString("base64url"), encrypted.toString("base64url"), tag.toString("base64url")].join(".");
330
271
  }
331
272
  decryptToken(token) {
332
- const [prefix, ivText, encryptedText, tagText, extraPart] = token.split(".");
333
- if (prefix !== "wkm1" || !ivText || !encryptedText || !tagText || extraPart) {
334
- throw new Error("Malformed token");
335
- }
336
- const decipher = createDecipheriv("aes-256-gcm", this.encryptionKey, Buffer.from(ivText, "base64url"));
337
- decipher.setAuthTag(Buffer.from(tagText, "base64url"));
338
- const plaintext = Buffer.concat([
339
- decipher.update(Buffer.from(encryptedText, "base64url")),
340
- decipher.final()
341
- ]).toString("utf8");
342
- const payload = JSON.parse(plaintext);
273
+ const payload = this.decryptValue("wkm1", token);
343
274
  if (payload.version !== 1 ||
344
275
  (payload.type !== "access" && payload.type !== "refresh") ||
345
276
  typeof payload.apiKey !== "string" ||
@@ -353,42 +284,157 @@ export class WukongOAuthProvider {
353
284
  }
354
285
  return payload;
355
286
  }
287
+ encryptTransientState(type, data) {
288
+ return this.encryptValue("wks1", { version: 1, type, data });
289
+ }
290
+ decryptTransientState(type, value) {
291
+ const decoded = this.decryptValue("wks1", value);
292
+ if (!decoded || typeof decoded !== "object") {
293
+ throw new Error("Invalid OAuth transient state payload");
294
+ }
295
+ const payload = decoded;
296
+ if (payload.version !== 1 || payload.type !== type) {
297
+ throw new Error("Invalid OAuth transient state payload");
298
+ }
299
+ return payload.data;
300
+ }
301
+ encryptValue(prefix, value) {
302
+ const iv = randomBytes(12);
303
+ const cipher = createCipheriv("aes-256-gcm", this.encryptionKey, iv);
304
+ const encrypted = Buffer.concat([
305
+ cipher.update(JSON.stringify(value), "utf8"),
306
+ cipher.final()
307
+ ]);
308
+ const tag = cipher.getAuthTag();
309
+ return [prefix, iv.toString("base64url"), encrypted.toString("base64url"), tag.toString("base64url")].join(".");
310
+ }
311
+ decryptValue(prefix, value) {
312
+ const [actualPrefix, ivText, encryptedText, tagText, extraPart] = value.split(".");
313
+ if (actualPrefix !== prefix || !ivText || !encryptedText || !tagText || extraPart) {
314
+ throw new Error("Malformed encrypted value");
315
+ }
316
+ const decipher = createDecipheriv("aes-256-gcm", this.encryptionKey, Buffer.from(ivText, "base64url"));
317
+ decipher.setAuthTag(Buffer.from(tagText, "base64url"));
318
+ const plaintext = Buffer.concat([
319
+ decipher.update(Buffer.from(encryptedText, "base64url")),
320
+ decipher.final()
321
+ ]).toString("utf8");
322
+ return JSON.parse(plaintext);
323
+ }
356
324
  assertResource(resource) {
357
325
  if (normalizeResourceUrl(resource).href !== this.resourceUrl.href) {
358
326
  throw new InvalidTargetError("请求的 resource 不是当前 MCP 服务。");
359
327
  }
360
328
  }
361
- cleanupTransientState() {
362
- const now = Date.now();
363
- for (const [requestId, pending] of this.pendingAuthorizations) {
364
- if (pending.expiresAt <= now) {
365
- this.pendingAuthorizations.delete(requestId);
366
- }
367
- }
368
- for (const [code, codeData] of this.authorizationCodes) {
369
- if (codeData.expiresAt <= now) {
370
- this.authorizationCodes.delete(code);
371
- }
372
- }
329
+ }
330
+ function serializePendingAuthorization(pending) {
331
+ return {
332
+ client: pending.client,
333
+ params: {
334
+ state: pending.params.state,
335
+ scopes: pending.params.scopes,
336
+ codeChallenge: pending.params.codeChallenge,
337
+ redirectUri: pending.params.redirectUri,
338
+ resource: pending.params.resource?.href
339
+ },
340
+ resource: pending.resource.href,
341
+ scopes: pending.scopes,
342
+ expiresAt: pending.expiresAt
343
+ };
344
+ }
345
+ function serializeAuthorizationCode(codeData) {
346
+ return {
347
+ ...serializePendingAuthorization(codeData),
348
+ apiKey: codeData.apiKey
349
+ };
350
+ }
351
+ function deserializePendingAuthorization(value) {
352
+ if (!isRecord(value) || !isRecord(value.client) || !isRecord(value.params)) {
353
+ throw new Error("Invalid OAuth pending authorization state");
373
354
  }
355
+ const client = value.client;
356
+ const params = value.params;
357
+ if (typeof client.client_id !== "string" ||
358
+ !isStringArray(client.redirect_uris) ||
359
+ typeof params.codeChallenge !== "string" ||
360
+ typeof params.redirectUri !== "string" ||
361
+ (params.state !== undefined && typeof params.state !== "string") ||
362
+ (params.scopes !== undefined && !isStringArray(params.scopes)) ||
363
+ (params.resource !== undefined && typeof params.resource !== "string") ||
364
+ typeof value.resource !== "string" ||
365
+ !isStringArray(value.scopes) ||
366
+ typeof value.expiresAt !== "number" ||
367
+ !Number.isFinite(value.expiresAt) ||
368
+ value.expiresAt <= Date.now()) {
369
+ throw new Error("Invalid or expired OAuth pending authorization state");
370
+ }
371
+ return {
372
+ client: value.client,
373
+ params: {
374
+ state: params.state,
375
+ scopes: params.scopes,
376
+ codeChallenge: params.codeChallenge,
377
+ redirectUri: params.redirectUri,
378
+ resource: params.resource === undefined ? undefined : new URL(params.resource)
379
+ },
380
+ resource: new URL(value.resource),
381
+ scopes: value.scopes,
382
+ expiresAt: value.expiresAt
383
+ };
384
+ }
385
+ function deserializeAuthorizationCode(value) {
386
+ if (!isRecord(value) || typeof value.apiKey !== "string" || !value.apiKey) {
387
+ throw new Error("Invalid OAuth authorization code state");
388
+ }
389
+ return {
390
+ ...deserializePendingAuthorization(value),
391
+ apiKey: value.apiKey
392
+ };
393
+ }
394
+ function isRecord(value) {
395
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
396
+ }
397
+ function isStringArray(value) {
398
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
374
399
  }
375
400
  function normalizeResourceUrl(url) {
376
401
  const normalized = new URL(url.href);
377
402
  normalized.hash = "";
378
403
  return normalized;
379
404
  }
380
- function normalizeBindingEndpoint(url, name) {
405
+ function normalizeBindingEndpoint(url, name, options = {}) {
381
406
  const normalized = new URL(url.href);
382
407
  const isLocalHttp = normalized.protocol === "http:" &&
383
408
  (normalized.hostname === "localhost" || normalized.hostname === "127.0.0.1");
384
- if (normalized.protocol !== "https:" && !isLocalHttp) {
385
- throw new Error(`${name} 必须使用 HTTPS(本机 localhost 调试除外)。`);
409
+ const isExplicitlyAllowedHttp = normalized.protocol === "http:" && options.allowHttp === true;
410
+ if (normalized.protocol !== "https:" && !isLocalHttp && !isExplicitlyAllowedHttp) {
411
+ const exchangeHint = name === "CRM_BINDING_EXCHANGE_URL"
412
+ ? ";可信内网 HTTP 可同时设置 CRM_BINDING_EXCHANGE_ALLOW_HTTP=true"
413
+ : "";
414
+ throw new Error(`${name} 必须使用 HTTPS(本机 localhost 调试除外${exchangeHint})。`);
386
415
  }
387
416
  if (normalized.username || normalized.password || normalized.search || normalized.hash) {
388
417
  throw new Error(`${name} 不能包含用户信息、查询参数或片段。`);
389
418
  }
390
419
  return normalized;
391
420
  }
421
+ function describeFetchError(error) {
422
+ const details = [];
423
+ let current = error;
424
+ for (let depth = 0; depth < 3 && current; depth++) {
425
+ if (current instanceof Error && current.message) {
426
+ details.push(current.message);
427
+ }
428
+ if (typeof current === "object" && "code" in current) {
429
+ const code = Reflect.get(current, "code");
430
+ if (typeof code === "string" && code) {
431
+ details.push(code);
432
+ }
433
+ }
434
+ current = current instanceof Error ? current.cause : undefined;
435
+ }
436
+ return [...new Set(details)].join("; ") || "unknown network error";
437
+ }
392
438
  function normalizeScopes(scopes) {
393
439
  const requested = scopes?.length ? [...new Set(scopes)] : [CRM_READ_SCOPE];
394
440
  for (const scope of requested) {
package/dist/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
+ import { createNocodeToolHandlers, nocodeToolDefinitions } from "./nocode.js";
3
4
  import { createToolHandlers } from "./tools.js";
4
5
  export const CRM_READ_SCOPE = "crm.read";
5
6
  export const CRM_WRITE_SCOPE = "crm.write";
@@ -180,13 +181,28 @@ export function createWukongMcpServer(config = {}) {
180
181
  const { oauth, ...crmConfig } = config;
181
182
  const server = new McpServer({
182
183
  name: "wukong-mcp",
183
- version: "0.1.4"
184
+ version: "0.2.0"
184
185
  }, {
185
186
  instructions: "先使用只读工具确认模块、记录和字段,再执行写入。所有写入必须显式传 confirm=true;不要尝试任意 URL 请求。"
186
187
  });
187
- const handlers = createToolHandlers(crmConfig);
188
- for (const [name, description, schema] of toolDefinitions) {
189
- const access = toolAccess(name, description);
188
+ const handlers = {
189
+ ...createToolHandlers(crmConfig),
190
+ ...createNocodeToolHandlers(crmConfig)
191
+ };
192
+ const registeredTools = [
193
+ ...toolDefinitions.map(([name, description, schema]) => ({
194
+ name,
195
+ description,
196
+ schema,
197
+ access: toolAccess(name, description),
198
+ destructive: isDestructiveTool(name)
199
+ })),
200
+ ...nocodeToolDefinitions.map((definition) => ({
201
+ ...definition,
202
+ destructive: definition.destructive ?? false
203
+ }))
204
+ ];
205
+ for (const { name, description, schema, access, destructive } of registeredTools) {
190
206
  const requiredScopes = access === "write"
191
207
  ? [CRM_READ_SCOPE, CRM_WRITE_SCOPE]
192
208
  : [CRM_READ_SCOPE];
@@ -199,7 +215,7 @@ export function createWukongMcpServer(config = {}) {
199
215
  annotations: {
200
216
  readOnlyHint: access === "read",
201
217
  openWorldHint: false,
202
- destructiveHint: isDestructiveTool(name),
218
+ destructiveHint: destructive,
203
219
  ...(access === "read" ? { idempotentHint: true } : {})
204
220
  },
205
221
  _meta: {
@@ -214,7 +230,11 @@ export function createWukongMcpServer(config = {}) {
214
230
  return insufficientScopeResult(requiredScopes, missingScopes, oauth.resourceMetadataUrl);
215
231
  }
216
232
  try {
217
- const result = await handlers[name](args ?? {});
233
+ const handler = handlers[name];
234
+ if (!handler) {
235
+ throw new Error(`未找到工具实现:${name}`);
236
+ }
237
+ const result = await handler(args ?? {});
218
238
  return toToolResult(result);
219
239
  }
220
240
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wukongcrm/mcp-server",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Local and remote MCP server for fixed 72CRM API access.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,9 +21,10 @@
21
21
  "build": "tsc -p tsconfig.json",
22
22
  "prepack": "npm run build",
23
23
  "pack:local": "npm pack",
24
- "test": "tsx --test test/client.test.ts test/server.test.ts test/oauth.test.ts test/http.test.ts test/tools.test.ts",
24
+ "test": "tsx --test test/client.test.ts test/server.test.ts test/oauth-state.test.ts test/oauth.test.ts test/http.test.ts test/tools.test.ts test/nocode.test.ts",
25
25
  "start": "node dist/index.js",
26
- "start:http": "node dist/http-entry.js"
26
+ "start:http": "node dist/http-entry.js",
27
+ "start:fc": "node dist/fc-entry.js"
27
28
  },
28
29
  "engines": {
29
30
  "node": ">=18"
@@ -31,6 +32,7 @@
31
32
  "dependencies": {
32
33
  "@modelcontextprotocol/sdk": "^1.29.0",
33
34
  "express": "^5.2.1",
35
+ "redis": "5.10.0",
34
36
  "zod": "^3.25.76"
35
37
  },
36
38
  "devDependencies": {