@wukongcrm/mcp-server 0.1.3 → 0.1.4
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 +940 -97
- package/dist/http-entry.d.ts +1 -0
- package/dist/http-entry.js +30 -0
- package/dist/http.d.ts +41 -0
- package/dist/http.js +253 -0
- package/dist/modules.js +4 -4
- package/dist/oauth.d.ts +71 -0
- package/dist/oauth.js +400 -0
- package/dist/server.d.ts +12 -1
- package/dist/server.js +218 -17
- package/dist/tools.js +3897 -540
- package/package.json +11 -15
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
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
|
+
import { AccessDeniedError, InvalidGrantError, InvalidScopeError, InvalidTargetError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
5
|
+
import { CrmClient } from "./client.js";
|
|
6
|
+
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
|
+
export class WukongOAuthProvider {
|
|
93
|
+
clientsStore;
|
|
94
|
+
resourceUrl;
|
|
95
|
+
crmBaseUrl;
|
|
96
|
+
fetchImpl;
|
|
97
|
+
crmBindingAuthorizeUrl;
|
|
98
|
+
crmBindingExchangeUrl;
|
|
99
|
+
crmBindingCallbackUrl;
|
|
100
|
+
crmBindingClientId;
|
|
101
|
+
crmBindingClientSecret;
|
|
102
|
+
encryptionKey;
|
|
103
|
+
accessTokenTtlSeconds;
|
|
104
|
+
refreshTokenTtlSeconds;
|
|
105
|
+
pendingAuthorizations = new Map();
|
|
106
|
+
authorizationCodes = new Map();
|
|
107
|
+
constructor(options) {
|
|
108
|
+
if (options.secret.length < 32) {
|
|
109
|
+
throw new Error("MCP_OAUTH_SECRET 至少需要 32 个字符。");
|
|
110
|
+
}
|
|
111
|
+
this.resourceUrl = normalizeResourceUrl(options.resourceUrl);
|
|
112
|
+
this.crmBaseUrl = options.crmBaseUrl;
|
|
113
|
+
this.fetchImpl = options.fetchImpl;
|
|
114
|
+
this.crmBindingAuthorizeUrl = normalizeBindingEndpoint(options.crmBindingAuthorizeUrl, "CRM_BINDING_AUTHORIZE_URL");
|
|
115
|
+
this.crmBindingExchangeUrl = normalizeBindingEndpoint(options.crmBindingExchangeUrl, "CRM_BINDING_EXCHANGE_URL");
|
|
116
|
+
this.crmBindingCallbackUrl = normalizeBindingEndpoint(options.crmBindingCallbackUrl, "CRM_BINDING_CALLBACK_URL");
|
|
117
|
+
if (!options.crmBindingClientId.trim()) {
|
|
118
|
+
throw new Error("CRM_BINDING_CLIENT_ID 不能为空。");
|
|
119
|
+
}
|
|
120
|
+
if (options.crmBindingClientSecret.length < 32) {
|
|
121
|
+
throw new Error("CRM_BINDING_CLIENT_SECRET 至少需要 32 个字符。");
|
|
122
|
+
}
|
|
123
|
+
this.crmBindingClientId = options.crmBindingClientId.trim();
|
|
124
|
+
this.crmBindingClientSecret = options.crmBindingClientSecret;
|
|
125
|
+
this.encryptionKey = createHash("sha256").update(options.secret, "utf8").digest();
|
|
126
|
+
this.accessTokenTtlSeconds = options.accessTokenTtlSeconds ?? 60 * 60;
|
|
127
|
+
this.refreshTokenTtlSeconds = options.refreshTokenTtlSeconds ?? 30 * 24 * 60 * 60;
|
|
128
|
+
this.clientsStore = new FileOAuthState(options.dataDirectory);
|
|
129
|
+
}
|
|
130
|
+
async authorize(client, params, res) {
|
|
131
|
+
this.cleanupTransientState();
|
|
132
|
+
const resource = normalizeResourceUrl(params.resource ?? this.resourceUrl);
|
|
133
|
+
this.assertResource(resource);
|
|
134
|
+
const scopes = normalizeScopes(params.scopes);
|
|
135
|
+
const requestId = randomUUID();
|
|
136
|
+
this.pendingAuthorizations.set(requestId, {
|
|
137
|
+
client,
|
|
138
|
+
params,
|
|
139
|
+
resource,
|
|
140
|
+
scopes,
|
|
141
|
+
expiresAt: Date.now() + 5 * 60 * 1000
|
|
142
|
+
});
|
|
143
|
+
const authorizationUrl = new URL(this.crmBindingAuthorizeUrl.href);
|
|
144
|
+
authorizationUrl.searchParams.set("client_id", this.crmBindingClientId);
|
|
145
|
+
authorizationUrl.searchParams.set("redirect_uri", this.crmBindingCallbackUrl.href);
|
|
146
|
+
authorizationUrl.searchParams.set("state", requestId);
|
|
147
|
+
res.setHeader("Cache-Control", "no-store");
|
|
148
|
+
res.setHeader("Pragma", "no-cache");
|
|
149
|
+
res.redirect(302, authorizationUrl.href);
|
|
150
|
+
}
|
|
151
|
+
async completeBindingAuthorization(requestId, bindingCode) {
|
|
152
|
+
this.cleanupTransientState();
|
|
153
|
+
const pending = this.pendingAuthorizations.get(requestId);
|
|
154
|
+
if (!pending) {
|
|
155
|
+
throw new AccessDeniedError("授权请求已失效,请返回 ChatGPT 重新授权。");
|
|
156
|
+
}
|
|
157
|
+
const normalizedCode = bindingCode.trim();
|
|
158
|
+
if (!normalizedCode || normalizedCode.length > 256) {
|
|
159
|
+
throw new AccessDeniedError("72CRM 返回的绑定码无效。");
|
|
160
|
+
}
|
|
161
|
+
const normalizedApiKey = await this.exchangeBindingCode(normalizedCode);
|
|
162
|
+
const crmClient = new CrmClient({
|
|
163
|
+
baseUrl: this.crmBaseUrl,
|
|
164
|
+
apiKey: normalizedApiKey,
|
|
165
|
+
fetchImpl: this.fetchImpl
|
|
166
|
+
});
|
|
167
|
+
const status = await crmClient.authStatus();
|
|
168
|
+
if (!status.ok) {
|
|
169
|
+
throw new AccessDeniedError("72CRM 静默生成的 API Key 验证失败,请重新授权。");
|
|
170
|
+
}
|
|
171
|
+
const code = randomUUID();
|
|
172
|
+
this.authorizationCodes.set(code, {
|
|
173
|
+
...pending,
|
|
174
|
+
expiresAt: Date.now() + 5 * 60 * 1000,
|
|
175
|
+
apiKey: normalizedApiKey
|
|
176
|
+
});
|
|
177
|
+
this.pendingAuthorizations.delete(requestId);
|
|
178
|
+
const target = new URL(pending.params.redirectUri);
|
|
179
|
+
target.searchParams.set("code", code);
|
|
180
|
+
if (pending.params.state) {
|
|
181
|
+
target.searchParams.set("state", pending.params.state);
|
|
182
|
+
}
|
|
183
|
+
return target.toString();
|
|
184
|
+
}
|
|
185
|
+
async exchangeBindingCode(bindingCode) {
|
|
186
|
+
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
187
|
+
const message = `${this.crmBindingClientId}\n${timestamp}\n${bindingCode}`;
|
|
188
|
+
const signature = createHmac("sha256", this.crmBindingClientSecret)
|
|
189
|
+
.update(message, "utf8")
|
|
190
|
+
.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
|
+
});
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
throw new AccessDeniedError("无法从 72CRM 完成静默凭证交换。");
|
|
203
|
+
}
|
|
204
|
+
const result = (await response.json());
|
|
205
|
+
const apiKey = result.code === 0 ? result.data?.apiKey?.trim() : undefined;
|
|
206
|
+
if (!apiKey || apiKey.length > 4096) {
|
|
207
|
+
throw new AccessDeniedError("72CRM 返回的静默凭证无效。");
|
|
208
|
+
}
|
|
209
|
+
return apiKey;
|
|
210
|
+
}
|
|
211
|
+
async challengeForAuthorizationCode(client, authorizationCode) {
|
|
212
|
+
const codeData = this.getAuthorizationCode(client, authorizationCode);
|
|
213
|
+
return codeData.params.codeChallenge;
|
|
214
|
+
}
|
|
215
|
+
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
|
|
216
|
+
const codeData = this.getAuthorizationCode(client, authorizationCode);
|
|
217
|
+
if (redirectUri && redirectUri !== codeData.params.redirectUri) {
|
|
218
|
+
throw new InvalidGrantError("redirect_uri 与授权请求不一致。");
|
|
219
|
+
}
|
|
220
|
+
if (resource && normalizeResourceUrl(resource).href !== codeData.resource.href) {
|
|
221
|
+
throw new InvalidTargetError("resource 与授权请求不一致。");
|
|
222
|
+
}
|
|
223
|
+
this.authorizationCodes.delete(authorizationCode);
|
|
224
|
+
return this.issueTokens(codeData.apiKey, client.client_id, codeData.scopes, codeData.resource);
|
|
225
|
+
}
|
|
226
|
+
async exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
227
|
+
const payload = await this.decodeAndVerifyToken(refreshToken, "refresh");
|
|
228
|
+
if (payload.clientId !== client.client_id) {
|
|
229
|
+
throw new InvalidGrantError("刷新令牌不属于当前客户端。");
|
|
230
|
+
}
|
|
231
|
+
const requestedResource = normalizeResourceUrl(resource ?? new URL(payload.resource));
|
|
232
|
+
if (requestedResource.href !== payload.resource) {
|
|
233
|
+
throw new InvalidTargetError("resource 与刷新令牌不一致。");
|
|
234
|
+
}
|
|
235
|
+
const requestedScopes = scopes?.length ? normalizeScopes(scopes) : payload.scopes;
|
|
236
|
+
if (requestedScopes.some((scope) => !payload.scopes.includes(scope))) {
|
|
237
|
+
throw new InvalidScopeError("刷新请求不能扩大原授权范围。");
|
|
238
|
+
}
|
|
239
|
+
await this.clientsStore.revoke(payload.jti, payload.expiresAt);
|
|
240
|
+
return this.issueTokens(payload.apiKey, client.client_id, requestedScopes, requestedResource);
|
|
241
|
+
}
|
|
242
|
+
async verifyAccessToken(token) {
|
|
243
|
+
const payload = await this.decodeAndVerifyToken(token, "access");
|
|
244
|
+
this.assertResource(new URL(payload.resource));
|
|
245
|
+
return {
|
|
246
|
+
token,
|
|
247
|
+
clientId: payload.clientId,
|
|
248
|
+
scopes: payload.scopes,
|
|
249
|
+
expiresAt: payload.expiresAt,
|
|
250
|
+
resource: new URL(payload.resource),
|
|
251
|
+
extra: {
|
|
252
|
+
crmApiKey: payload.apiKey
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
async revokeToken(client, request) {
|
|
257
|
+
try {
|
|
258
|
+
const payload = this.decryptToken(request.token);
|
|
259
|
+
if (payload.clientId === client.client_id) {
|
|
260
|
+
await this.clientsStore.revoke(payload.jti, payload.expiresAt);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// RFC 7009 requires an invalid token to produce the same successful response.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
getAuthorizationCode(client, authorizationCode) {
|
|
268
|
+
this.cleanupTransientState();
|
|
269
|
+
const codeData = this.authorizationCodes.get(authorizationCode);
|
|
270
|
+
if (!codeData || codeData.client.client_id !== client.client_id) {
|
|
271
|
+
throw new InvalidGrantError("授权码无效或已过期。");
|
|
272
|
+
}
|
|
273
|
+
return codeData;
|
|
274
|
+
}
|
|
275
|
+
issueTokens(apiKey, clientId, scopes, resource) {
|
|
276
|
+
const now = Math.floor(Date.now() / 1000);
|
|
277
|
+
const accessPayload = {
|
|
278
|
+
version: 1,
|
|
279
|
+
type: "access",
|
|
280
|
+
apiKey,
|
|
281
|
+
clientId,
|
|
282
|
+
scopes,
|
|
283
|
+
resource: resource.href,
|
|
284
|
+
issuedAt: now,
|
|
285
|
+
expiresAt: now + this.accessTokenTtlSeconds,
|
|
286
|
+
jti: randomUUID()
|
|
287
|
+
};
|
|
288
|
+
const tokens = {
|
|
289
|
+
access_token: this.encryptToken(accessPayload),
|
|
290
|
+
token_type: "Bearer",
|
|
291
|
+
expires_in: this.accessTokenTtlSeconds,
|
|
292
|
+
scope: scopes.join(" ")
|
|
293
|
+
};
|
|
294
|
+
if (scopes.includes(OFFLINE_ACCESS_SCOPE)) {
|
|
295
|
+
tokens.refresh_token = this.encryptToken({
|
|
296
|
+
...accessPayload,
|
|
297
|
+
type: "refresh",
|
|
298
|
+
expiresAt: now + this.refreshTokenTtlSeconds,
|
|
299
|
+
jti: randomUUID()
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return tokens;
|
|
303
|
+
}
|
|
304
|
+
async decodeAndVerifyToken(token, expectedType) {
|
|
305
|
+
let payload;
|
|
306
|
+
try {
|
|
307
|
+
payload = this.decryptToken(token);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
throw new InvalidTokenError("Invalid token.");
|
|
311
|
+
}
|
|
312
|
+
const now = Math.floor(Date.now() / 1000);
|
|
313
|
+
if (payload.type !== expectedType || payload.expiresAt <= now) {
|
|
314
|
+
throw new InvalidTokenError("Invalid or expired token.");
|
|
315
|
+
}
|
|
316
|
+
if (await this.clientsStore.isRevoked(payload.jti)) {
|
|
317
|
+
throw new InvalidTokenError("Token has been revoked.");
|
|
318
|
+
}
|
|
319
|
+
return payload;
|
|
320
|
+
}
|
|
321
|
+
encryptToken(payload) {
|
|
322
|
+
const iv = randomBytes(12);
|
|
323
|
+
const cipher = createCipheriv("aes-256-gcm", this.encryptionKey, iv);
|
|
324
|
+
const encrypted = Buffer.concat([
|
|
325
|
+
cipher.update(JSON.stringify(payload), "utf8"),
|
|
326
|
+
cipher.final()
|
|
327
|
+
]);
|
|
328
|
+
const tag = cipher.getAuthTag();
|
|
329
|
+
return ["wkm1", iv.toString("base64url"), encrypted.toString("base64url"), tag.toString("base64url")].join(".");
|
|
330
|
+
}
|
|
331
|
+
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);
|
|
343
|
+
if (payload.version !== 1 ||
|
|
344
|
+
(payload.type !== "access" && payload.type !== "refresh") ||
|
|
345
|
+
typeof payload.apiKey !== "string" ||
|
|
346
|
+
typeof payload.clientId !== "string" ||
|
|
347
|
+
!Array.isArray(payload.scopes) ||
|
|
348
|
+
typeof payload.resource !== "string" ||
|
|
349
|
+
typeof payload.issuedAt !== "number" ||
|
|
350
|
+
typeof payload.expiresAt !== "number" ||
|
|
351
|
+
typeof payload.jti !== "string") {
|
|
352
|
+
throw new Error("Invalid token payload");
|
|
353
|
+
}
|
|
354
|
+
return payload;
|
|
355
|
+
}
|
|
356
|
+
assertResource(resource) {
|
|
357
|
+
if (normalizeResourceUrl(resource).href !== this.resourceUrl.href) {
|
|
358
|
+
throw new InvalidTargetError("请求的 resource 不是当前 MCP 服务。");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
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
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function normalizeResourceUrl(url) {
|
|
376
|
+
const normalized = new URL(url.href);
|
|
377
|
+
normalized.hash = "";
|
|
378
|
+
return normalized;
|
|
379
|
+
}
|
|
380
|
+
function normalizeBindingEndpoint(url, name) {
|
|
381
|
+
const normalized = new URL(url.href);
|
|
382
|
+
const isLocalHttp = normalized.protocol === "http:" &&
|
|
383
|
+
(normalized.hostname === "localhost" || normalized.hostname === "127.0.0.1");
|
|
384
|
+
if (normalized.protocol !== "https:" && !isLocalHttp) {
|
|
385
|
+
throw new Error(`${name} 必须使用 HTTPS(本机 localhost 调试除外)。`);
|
|
386
|
+
}
|
|
387
|
+
if (normalized.username || normalized.password || normalized.search || normalized.hash) {
|
|
388
|
+
throw new Error(`${name} 不能包含用户信息、查询参数或片段。`);
|
|
389
|
+
}
|
|
390
|
+
return normalized;
|
|
391
|
+
}
|
|
392
|
+
function normalizeScopes(scopes) {
|
|
393
|
+
const requested = scopes?.length ? [...new Set(scopes)] : [CRM_READ_SCOPE];
|
|
394
|
+
for (const scope of requested) {
|
|
395
|
+
if (!OAUTH_SCOPES.includes(scope)) {
|
|
396
|
+
throw new InvalidScopeError(`不支持的授权范围:${scope}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return requested;
|
|
400
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { CrmClientConfig } from "./client.js";
|
|
3
|
-
export declare
|
|
3
|
+
export declare const CRM_READ_SCOPE = "crm.read";
|
|
4
|
+
export declare const CRM_WRITE_SCOPE = "crm.write";
|
|
5
|
+
export declare const OFFLINE_ACCESS_SCOPE = "offline_access";
|
|
6
|
+
export declare const MCP_RESOURCE_SCOPES: readonly ["crm.read", "crm.write"];
|
|
7
|
+
export declare const OAUTH_SCOPES: readonly ["crm.read", "crm.write", "offline_access"];
|
|
8
|
+
export interface WukongMcpServerConfig extends CrmClientConfig {
|
|
9
|
+
oauth?: {
|
|
10
|
+
scopes: string[];
|
|
11
|
+
resourceMetadataUrl: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export declare function createWukongMcpServer(config?: WukongMcpServerConfig): McpServer;
|