@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/README.md +32 -3
- package/dist/fc-entry.d.ts +1 -0
- package/dist/fc-entry.js +7 -0
- package/dist/http-entry.js +23 -2
- package/dist/http.d.ts +6 -0
- package/dist/http.js +298 -1
- package/dist/nocode.d.ts +16 -0
- package/dist/nocode.js +1086 -0
- package/dist/oauth-state.d.ts +94 -0
- package/dist/oauth-state.js +310 -0
- package/dist/oauth.d.ts +13 -25
- package/dist/oauth.js +188 -142
- package/dist/server.js +26 -6
- package/package.json +5 -3
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 =
|
|
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
|
-
|
|
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.
|
|
153
|
-
const pending =
|
|
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
|
-
|
|
94
|
+
const codeData = {
|
|
173
95
|
...pending,
|
|
174
96
|
expiresAt: Date.now() + 5 * 60 * 1000,
|
|
175
97
|
apiKey: normalizedApiKey
|
|
176
|
-
}
|
|
177
|
-
this.
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
224
|
-
return this.issueTokens(
|
|
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.
|
|
269
|
-
const codeData =
|
|
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
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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.
|
|
184
|
+
version: "0.2.0"
|
|
184
185
|
}, {
|
|
185
186
|
instructions: "先使用只读工具确认模块、记录和字段,再执行写入。所有写入必须显式传 confirm=true;不要尝试任意 URL 请求。"
|
|
186
187
|
});
|
|
187
|
-
const handlers =
|
|
188
|
-
|
|
189
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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": {
|