dsh-mcp-connector 0.2.21 → 0.2.23
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/CHANGELOG.md +30 -1
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/docs/STDIO-SUPPORT.md +2 -0
- package/docs/USER-GUIDE.md +6 -3
- package/lib/connectors/oauth-connector.js +10 -2
- package/lib/constants.js +5 -0
- package/lib/grant-lifecycle.js +47 -0
- package/lib/index.js +739 -116
- package/lib/mcp-provision.js +5 -5
- package/lib/mcp-validation.js +25 -3
- package/package.json +2 -2
- package/ui/index.html +33 -8
package/lib/index.js
CHANGED
|
@@ -15,7 +15,10 @@ import {
|
|
|
15
15
|
DEFAULT_CATALOG_URL,
|
|
16
16
|
DEFAULT_CATALOG_FALLBACK_URLS,
|
|
17
17
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
18
|
+
DEFAULT_STARTUP_TIMEOUT_MS,
|
|
18
19
|
DEFAULT_REFRESH_SKEW_MS,
|
|
20
|
+
DEFAULT_REFRESH_RETRY_BASE_MS,
|
|
21
|
+
DEFAULT_REFRESH_RETRY_MAX_MS,
|
|
19
22
|
DEFAULT_CATALOG_TTL_MS,
|
|
20
23
|
} from './constants.js';
|
|
21
24
|
import { defineConnectorDomain, ConnectionStore, GrantStore, CatalogStore } from './stores.js';
|
|
@@ -24,7 +27,7 @@ import { loadBundledCatalog, fetchRemoteCatalogWithFallback, mergeCatalog, listC
|
|
|
24
27
|
import { oauthAuthorize } from './connectors/oauth-connector.js';
|
|
25
28
|
import { buildManualRecord } from './connectors/manual-connector.js';
|
|
26
29
|
import { normalizeJsonImport } from './connectors/json-connector.js';
|
|
27
|
-
import { discoverServerMetadata, refreshAccessToken, revokeRefreshToken } from './oauth.js';
|
|
30
|
+
import { discoverServerMetadata, refreshAccessToken, revokeRefreshToken, extractTokenResources } from './oauth.js';
|
|
28
31
|
import { provision, disable, remove, entryIdFor } from './mcp-provision.js';
|
|
29
32
|
import { classifyConnectionError, validateConnectionRecords } from './mcp-validation.js';
|
|
30
33
|
import { listMcpTools, McpHttpError } from './mcp-http.js';
|
|
@@ -32,9 +35,21 @@ import { registerTools } from './tools.js';
|
|
|
32
35
|
import { mountWebRoutes } from './web.js';
|
|
33
36
|
import { assertSafeUrl } from './util.js';
|
|
34
37
|
import { readLegacyGrantCandidates, planLegacyMigration, toConnectorGrant, toConnectionRecords } from './migration.js';
|
|
38
|
+
import { classifyRefreshFailure, refreshRetryDelay } from './grant-lifecycle.js';
|
|
35
39
|
|
|
36
40
|
export const name = 'mcp-connector';
|
|
37
41
|
|
|
42
|
+
const LEGACY_OAUTH_PLUGINS = [
|
|
43
|
+
{
|
|
44
|
+
id: 'qcc-mcp-oauth',
|
|
45
|
+
servers: ['qcc-company', 'qcc-risk', 'qcc-ipr', 'qcc-operation', 'qcc-history', 'qcc-executive'],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'qcc-legal-mcp-oauth',
|
|
49
|
+
servers: ['qcc-regulation', 'qcc-case'],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
38
53
|
export const inject = ['tools', 'storageDomain', 'loader'];
|
|
39
54
|
|
|
40
55
|
export const Config = z.object({
|
|
@@ -52,8 +67,14 @@ export const Config = z.object({
|
|
|
52
67
|
callbackTimeoutMs: z.number().default(300_000),
|
|
53
68
|
/** 网络请求超时(ms) */
|
|
54
69
|
requestTimeoutMs: z.number().default(DEFAULT_REQUEST_TIMEOUT_MS),
|
|
70
|
+
/** 首次启动并完成 MCP initialize + tools/list 的超时(ms) */
|
|
71
|
+
startupTimeoutMs: z.number().default(DEFAULT_STARTUP_TIMEOUT_MS),
|
|
55
72
|
/** 提前刷新阈值(ms) */
|
|
56
73
|
refreshSkewMs: z.number().default(DEFAULT_REFRESH_SKEW_MS),
|
|
74
|
+
/** 刷新暂时失败后的首次重试间隔(ms) */
|
|
75
|
+
refreshRetryBaseMs: z.number().default(DEFAULT_REFRESH_RETRY_BASE_MS),
|
|
76
|
+
/** 刷新暂时失败后的最大重试间隔(ms) */
|
|
77
|
+
refreshRetryMaxMs: z.number().default(DEFAULT_REFRESH_RETRY_MAX_MS),
|
|
57
78
|
/** 是否自动打开浏览器(false = 仅打印授权 URL) */
|
|
58
79
|
openBrowser: z.boolean().default(true),
|
|
59
80
|
/** 授权账号标识(预留多账号) */
|
|
@@ -78,9 +99,10 @@ export async function apply(ctx, config) {
|
|
|
78
99
|
dynamic: new Map(), // URL 安装的临时连接器
|
|
79
100
|
overrides: new Map(), // id -> { published?, featured? }
|
|
80
101
|
connections: new Map(), // key -> ConnectionRecord
|
|
81
|
-
grants: new Map(), // grantKey -> { grant, timer, refreshPromise, needsReauth }
|
|
102
|
+
grants: new Map(), // grantKey -> { grant, timer, refreshPromise, needsReauth, refreshFailure* }
|
|
82
103
|
health: new Map(), // connectorId -> 最近一次主动连通性检查摘要(不持久化)
|
|
83
|
-
oauthConnects: new Map(), //
|
|
104
|
+
oauthConnects: new Map(), // sharingKey -> { promise, requestedConnectorIds }(防同 issuer 重复弹窗)
|
|
105
|
+
warnedLegacyConflicts: new Set(),
|
|
84
106
|
};
|
|
85
107
|
|
|
86
108
|
/* ───────────────────────── 目录加载 ───────────────────────── */
|
|
@@ -151,8 +173,91 @@ export async function apply(ctx, config) {
|
|
|
151
173
|
await recomputeMerged();
|
|
152
174
|
}
|
|
153
175
|
|
|
176
|
+
/* ───────────────────────── 旧 OAuth 插件冲突 ───────────────────────── */
|
|
177
|
+
|
|
178
|
+
function loaderEntryActive(id) {
|
|
179
|
+
try {
|
|
180
|
+
const entry = ctx.loader.resolve(id);
|
|
181
|
+
return !!entry && entry.disabled !== true && entry.options?.disabled !== true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function activeLegacyPlugin(definition) {
|
|
188
|
+
if (loaderEntryActive(definition.id)) return definition.id;
|
|
189
|
+
// 兼容旧插件本体已经移除、但其动态 mcp-client 条目仍残留的配置树。
|
|
190
|
+
return definition.servers.some((serverName) => loaderEntryActive(`mcp-${serverName}`))
|
|
191
|
+
? definition.id
|
|
192
|
+
: null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function legacyConflictForServer(serverName) {
|
|
196
|
+
for (const definition of LEGACY_OAUTH_PLUGINS) {
|
|
197
|
+
if (definition.servers.includes(serverName) && activeLegacyPlugin(definition)) return definition.id;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function legacyConflictForRecord(record) {
|
|
203
|
+
const pluginId = legacyConflictForServer(record?.serverName);
|
|
204
|
+
if (!pluginId) return null;
|
|
205
|
+
return {
|
|
206
|
+
pluginId,
|
|
207
|
+
serverName: record.serverName,
|
|
208
|
+
message: `检测到旧插件 ${pluginId} 仍在管理同名服务 ${record.serverName};请停用旧插件并重启 DSH`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function legacyConflictForConnector(connector) {
|
|
213
|
+
for (const server of connector?.servers ?? []) {
|
|
214
|
+
const pluginId = legacyConflictForServer(server.serverName);
|
|
215
|
+
if (pluginId) {
|
|
216
|
+
return {
|
|
217
|
+
pluginId,
|
|
218
|
+
serverName: server.serverName,
|
|
219
|
+
message: `检测到旧插件 ${pluginId} 仍在管理同名服务 ${server.serverName};为避免凭据覆盖,请停用旧插件并重启 DSH 后再连接`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function warnLegacyConflict(conflict) {
|
|
227
|
+
if (!conflict || state.warnedLegacyConflicts.has(conflict.pluginId)) return;
|
|
228
|
+
state.warnedLegacyConflicts.add(conflict.pluginId);
|
|
229
|
+
logger.warn(`legacy OAuth plugin conflict: ${conflict.message}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function detectLegacyPluginConflicts() {
|
|
233
|
+
const conflicts = [];
|
|
234
|
+
for (const definition of LEGACY_OAUTH_PLUGINS) {
|
|
235
|
+
if (!activeLegacyPlugin(definition)) continue;
|
|
236
|
+
const conflict = {
|
|
237
|
+
pluginId: definition.id,
|
|
238
|
+
message: `检测到旧插件 ${definition.id} 仍处于启用状态;它会与 MCP连接器重复管理企查查 Server`,
|
|
239
|
+
};
|
|
240
|
+
conflicts.push(conflict);
|
|
241
|
+
warnLegacyConflict(conflict);
|
|
242
|
+
}
|
|
243
|
+
return conflicts;
|
|
244
|
+
}
|
|
245
|
+
|
|
154
246
|
/* ───────────────────────── grant 刷新 ───────────────────────── */
|
|
155
247
|
|
|
248
|
+
function grantRuntime(grant) {
|
|
249
|
+
return {
|
|
250
|
+
grant,
|
|
251
|
+
timer: undefined,
|
|
252
|
+
refreshPromise: null,
|
|
253
|
+
needsReauth: false,
|
|
254
|
+
refreshFailureKind: null,
|
|
255
|
+
refreshFailureCount: 0,
|
|
256
|
+
lastRefreshError: null,
|
|
257
|
+
refreshRetryAt: null,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
156
261
|
function grantMap() {
|
|
157
262
|
const map = new Map();
|
|
158
263
|
for (const [key, g] of state.grants) map.set(key, g.grant);
|
|
@@ -161,10 +266,16 @@ export async function apply(ctx, config) {
|
|
|
161
266
|
|
|
162
267
|
async function refreshGrant(grantKey) {
|
|
163
268
|
const entry = state.grants.get(grantKey);
|
|
164
|
-
if (!entry || !entry.grant.refreshToken)
|
|
269
|
+
if (!entry || !entry.grant.refreshToken) {
|
|
270
|
+
const error = new Error('no refresh token available');
|
|
271
|
+
error.code = 'missing_refresh_token';
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
165
274
|
if (entry.grant.clientSecret && entry.grant.clientSecretExpiresAt > 0
|
|
166
275
|
&& entry.grant.clientSecretExpiresAt * 1000 <= Date.now()) {
|
|
167
|
-
|
|
276
|
+
const error = new Error('OAuth dynamic client secret expired');
|
|
277
|
+
error.code = 'client_secret_expired';
|
|
278
|
+
throw error;
|
|
168
279
|
}
|
|
169
280
|
if (entry.refreshPromise) return entry.refreshPromise;
|
|
170
281
|
entry.refreshPromise = (async () => {
|
|
@@ -174,22 +285,39 @@ export async function apply(ctx, config) {
|
|
|
174
285
|
clientSecret: entry.grant.clientSecret,
|
|
175
286
|
tokenEndpointAuthMethod: entry.grant.tokenEndpointAuthMethod ?? 'none',
|
|
176
287
|
refreshToken: entry.grant.refreshToken,
|
|
177
|
-
resource
|
|
288
|
+
// 多资源共享 Grant 不应在刷新时被第一个 resource 意外收窄。
|
|
289
|
+
resource: entry.grant.authorizedResources.length === 1 ? entry.grant.authorizedResources[0] : undefined,
|
|
178
290
|
scope: entry.grant.scope,
|
|
179
291
|
timeoutMs: config.requestTimeoutMs,
|
|
180
292
|
});
|
|
293
|
+
const refreshedResources = extractTokenResources(token.accessToken);
|
|
181
294
|
const next = {
|
|
182
295
|
...entry.grant,
|
|
183
296
|
accessToken: token.accessToken,
|
|
184
|
-
|
|
297
|
+
// 持久化真实过期时间;提前刷新只用于定时调度,不能冒充 Token 已过期。
|
|
298
|
+
accessTokenExpiresAt: Date.now() + token.expiresIn * 1000,
|
|
185
299
|
refreshToken: token.refreshToken ?? entry.grant.refreshToken,
|
|
300
|
+
authorizedResources: refreshedResources?.length ? refreshedResources : entry.grant.authorizedResources,
|
|
301
|
+
updatedAt: Date.now(),
|
|
186
302
|
};
|
|
187
303
|
entry.grant = next;
|
|
188
304
|
entry.needsReauth = false;
|
|
305
|
+
entry.refreshFailureKind = null;
|
|
306
|
+
entry.refreshFailureCount = 0;
|
|
307
|
+
entry.lastRefreshError = null;
|
|
308
|
+
entry.refreshRetryAt = null;
|
|
189
309
|
state.grants.set(grantKey, entry);
|
|
190
310
|
if (config.persistSecrets) await grantStore.put(next);
|
|
191
311
|
for (const [key, record] of state.connections) {
|
|
192
|
-
if (record.auth?.grantKey
|
|
312
|
+
if (record.auth?.grantKey !== grantKey || record.enabled === false) continue;
|
|
313
|
+
const conflict = legacyConflictForRecord(record);
|
|
314
|
+
if (conflict) {
|
|
315
|
+
warnLegacyConflict(conflict);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
await provision(ctx, config, record, grantMap()).catch((error) => {
|
|
319
|
+
logger.warn(`re-provision "${key}" after OAuth refresh failed: ${error.message}`);
|
|
320
|
+
});
|
|
193
321
|
}
|
|
194
322
|
scheduleRefresh(grantKey);
|
|
195
323
|
return next;
|
|
@@ -201,20 +329,61 @@ export async function apply(ctx, config) {
|
|
|
201
329
|
}
|
|
202
330
|
}
|
|
203
331
|
|
|
204
|
-
function scheduleRefresh(grantKey) {
|
|
332
|
+
function scheduleRefresh(grantKey, { delayMs } = {}) {
|
|
205
333
|
const entry = state.grants.get(grantKey);
|
|
206
334
|
if (!entry) return;
|
|
207
335
|
if (entry.timer) clearTimeout(entry.timer);
|
|
208
|
-
const
|
|
336
|
+
const tokenLifetime = Math.max(0, entry.grant.accessTokenExpiresAt - (entry.grant.updatedAt || Date.now()));
|
|
337
|
+
const effectiveSkew = Math.min(
|
|
338
|
+
Math.max(0, config.refreshSkewMs),
|
|
339
|
+
Math.max(1_000, Math.floor(tokenLifetime * 0.1)),
|
|
340
|
+
);
|
|
341
|
+
const delay = delayMs === undefined
|
|
342
|
+
? Math.max(0, entry.grant.accessTokenExpiresAt - effectiveSkew - Date.now())
|
|
343
|
+
: Math.max(0, delayMs);
|
|
209
344
|
entry.timer = setTimeout(() => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
logger.warn(`grant ${grantKey} refresh failed: ${error.message}`);
|
|
213
|
-
});
|
|
345
|
+
entry.timer = undefined;
|
|
346
|
+
refreshGrantWithRecovery(grantKey, 'scheduled').catch(() => {});
|
|
214
347
|
}, delay);
|
|
215
348
|
entry.timer.unref?.();
|
|
216
349
|
}
|
|
217
350
|
|
|
351
|
+
function recordRefreshFailure(grantKey, error, phase) {
|
|
352
|
+
const entry = state.grants.get(grantKey);
|
|
353
|
+
if (!entry) return classifyRefreshFailure(error);
|
|
354
|
+
const failure = classifyRefreshFailure(error);
|
|
355
|
+
entry.refreshFailureCount = (entry.refreshFailureCount ?? 0) + 1;
|
|
356
|
+
entry.refreshFailureKind = failure.kind;
|
|
357
|
+
entry.lastRefreshError = failure.message;
|
|
358
|
+
entry.needsReauth = failure.permanent;
|
|
359
|
+
entry.refreshRetryAt = null;
|
|
360
|
+
|
|
361
|
+
const status = failure.httpStatus ? ` http=${failure.httpStatus}` : '';
|
|
362
|
+
const action = failure.permanent ? 'reauthorization required' : 'automatic retry scheduled';
|
|
363
|
+
logger.warn(`OAuth grant refresh failed phase=${phase} grant=${grantKey} code=${failure.code}${status} classification=${failure.kind}: ${failure.message}; ${action}`);
|
|
364
|
+
|
|
365
|
+
if (!failure.permanent) {
|
|
366
|
+
const delay = refreshRetryDelay(
|
|
367
|
+
entry.refreshFailureCount,
|
|
368
|
+
config.refreshRetryBaseMs,
|
|
369
|
+
config.refreshRetryMaxMs,
|
|
370
|
+
);
|
|
371
|
+
entry.refreshRetryAt = Date.now() + delay;
|
|
372
|
+
scheduleRefresh(grantKey, { delayMs: delay });
|
|
373
|
+
}
|
|
374
|
+
return failure;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function refreshGrantWithRecovery(grantKey, phase) {
|
|
378
|
+
try {
|
|
379
|
+
return await refreshGrant(grantKey);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
// 多个调用方可能等待同一个 refreshPromise;同一次失败只能记一次并调度一个重试。
|
|
382
|
+
if (!error.refreshFailure) error.refreshFailure = recordRefreshFailure(grantKey, error, phase);
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
218
387
|
async function retireGrantIfUnused(grantKey, { revoke = false } = {}) {
|
|
219
388
|
if ([...state.connections.values()].some((record) => record.auth?.grantKey === grantKey)) return false;
|
|
220
389
|
const entry = state.grants.get(grantKey);
|
|
@@ -251,48 +420,225 @@ export async function apply(ctx, config) {
|
|
|
251
420
|
|
|
252
421
|
/* ───────────────────────── 连接记录落库 / 挂载 ───────────────────────── */
|
|
253
422
|
|
|
423
|
+
function clearRecordHealth(record) {
|
|
424
|
+
if (record?.connectorId) state.health.delete(record.connectorId);
|
|
425
|
+
}
|
|
426
|
+
|
|
254
427
|
async function persistRecord(record) {
|
|
255
|
-
|
|
256
|
-
if (record.connectorId) state.health.delete(record.connectorId);
|
|
428
|
+
// 存储成功后才更新内存,避免落盘失败却在当前进程显示为“已安装”。
|
|
257
429
|
if (config.persistSecrets) await connectionStore.put(record);
|
|
430
|
+
state.connections.set(record.key, record);
|
|
431
|
+
clearRecordHealth(record);
|
|
258
432
|
}
|
|
259
433
|
|
|
260
|
-
async function
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
434
|
+
async function restorePersistedRecord(key, original) {
|
|
435
|
+
const current = state.connections.get(key);
|
|
436
|
+
if (config.persistSecrets) {
|
|
437
|
+
if (original) await connectionStore.put(original);
|
|
438
|
+
else await connectionStore.delete(key);
|
|
439
|
+
}
|
|
440
|
+
if (original) state.connections.set(key, original);
|
|
441
|
+
else state.connections.delete(key);
|
|
442
|
+
clearRecordHealth(current);
|
|
443
|
+
clearRecordHealth(original);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function restoreProvisionedEntry(record, original) {
|
|
447
|
+
if (original) await provision(ctx, config, original, grantMap(), { failOnStartupError: false });
|
|
448
|
+
else await remove(ctx, config, record);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function provisionUntilReady(record, original, { strictStartup = true } = {}) {
|
|
452
|
+
if (!strictStartup) {
|
|
453
|
+
return provision(ctx, config, record, grantMap(), { failOnStartupError: false });
|
|
454
|
+
}
|
|
455
|
+
const startupTimeoutMs = Math.max(1_000, Number(config.startupTimeoutMs) || DEFAULT_STARTUP_TIMEOUT_MS);
|
|
456
|
+
const timeoutError = Object.assign(
|
|
457
|
+
new Error(`MCP Server 启动与工具同步超过 ${Math.ceil(startupTimeoutMs / 1000)} 秒`),
|
|
458
|
+
{ code: 'ETIMEDOUT' },
|
|
459
|
+
);
|
|
460
|
+
let timer;
|
|
461
|
+
const task = provision(ctx, config, record, grantMap(), { failOnStartupError: true });
|
|
462
|
+
const deadline = new Promise((_, reject) => {
|
|
463
|
+
timer = setTimeout(() => reject(timeoutError), startupTimeoutMs);
|
|
464
|
+
timer.unref?.();
|
|
465
|
+
});
|
|
466
|
+
try {
|
|
467
|
+
return await Promise.race([task, deadline]);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (error === timeoutError) {
|
|
470
|
+
// loader 可能仍在等待 SDK 请求;它最终成功时必须恢复旧条目/移除新条目,绝不迟到落库。
|
|
471
|
+
task.then(
|
|
472
|
+
() => restoreProvisionedEntry(record, original).catch((rollbackError) => {
|
|
473
|
+
logger.error(`late startup rollback "${record.key}" failed: ${rollbackError.message}`);
|
|
474
|
+
}),
|
|
475
|
+
() => {},
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
throw error;
|
|
479
|
+
} finally {
|
|
480
|
+
clearTimeout(timer);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function rollbackProvisionedRecords(records, originals) {
|
|
485
|
+
const failures = [];
|
|
486
|
+
for (const record of [...records].reverse()) {
|
|
487
|
+
try {
|
|
488
|
+
await restoreProvisionedEntry(record, originals.get(record.key));
|
|
489
|
+
} catch (error) {
|
|
490
|
+
failures.push(error);
|
|
491
|
+
logger.error(`connection rollback "${record.key}" failed: ${error.message}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return failures;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function commitConnectionRecords(records, { strictStartup = true } = {}) {
|
|
498
|
+
const originals = new Map(records.map((record) => [record.key, state.connections.get(record.key)]));
|
|
499
|
+
const staged = records.map((record) => {
|
|
500
|
+
const existing = originals.get(record.key);
|
|
501
|
+
return existing ? { ...existing, ...record, updatedAt: Date.now() } : record;
|
|
502
|
+
});
|
|
503
|
+
const provisioned = [];
|
|
504
|
+
let activeRecord;
|
|
505
|
+
try {
|
|
506
|
+
for (const record of staged) {
|
|
507
|
+
activeRecord = record;
|
|
508
|
+
await provisionUntilReady(record, originals.get(record.key), { strictStartup });
|
|
509
|
+
provisioned.push(record);
|
|
510
|
+
}
|
|
511
|
+
} catch (error) {
|
|
512
|
+
await rollbackProvisionedRecords(provisioned, originals);
|
|
513
|
+
const wrapped = new Error(`MCP Server "${activeRecord?.serverName ?? activeRecord?.key ?? 'unknown'}" 未完成启动`, { cause: error });
|
|
514
|
+
wrapped.connectionRecord = activeRecord;
|
|
515
|
+
throw wrapped;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
for (const record of staged) await persistRecord(record);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
const rollbackErrors = await rollbackProvisionedRecords(provisioned, originals);
|
|
522
|
+
for (const record of staged) {
|
|
523
|
+
try {
|
|
524
|
+
await restorePersistedRecord(record.key, originals.get(record.key));
|
|
525
|
+
} catch (rollbackError) {
|
|
526
|
+
rollbackErrors.push(rollbackError);
|
|
527
|
+
logger.error(`connection storage rollback "${record.key}" failed: ${rollbackError.message}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const wrapped = new Error('连接记录持久化失败,已回滚 Host 条目', { cause: error });
|
|
531
|
+
wrapped.connectionRecord = activeRecord;
|
|
532
|
+
wrapped.rollbackErrors = rollbackErrors;
|
|
533
|
+
throw wrapped;
|
|
534
|
+
}
|
|
535
|
+
return staged;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function upsertRecord(record, options) {
|
|
539
|
+
return (await commitConnectionRecords([record], options))[0];
|
|
266
540
|
}
|
|
267
541
|
|
|
268
542
|
function grantFailure(record) {
|
|
269
543
|
if (record.auth?.mode !== 'oauth') return null;
|
|
544
|
+
const conflict = legacyConflictForRecord(record);
|
|
545
|
+
if (conflict) {
|
|
546
|
+
warnLegacyConflict(conflict);
|
|
547
|
+
return { ok: false, kind: 'conflict', serverKey: record.serverKey, serverName: record.serverName, message: conflict.message };
|
|
548
|
+
}
|
|
270
549
|
const grantKey = record.auth.grantKey;
|
|
271
550
|
const entry = grantKey ? state.grants.get(grantKey) : null;
|
|
272
551
|
if (!entry) return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权缺失,请重新授权' };
|
|
273
552
|
const clientSecretExpired = entry.grant.clientSecret && entry.grant.clientSecretExpiresAt > 0
|
|
274
553
|
&& entry.grant.clientSecretExpiresAt * 1000 <= Date.now();
|
|
275
|
-
if (entry.needsReauth || clientSecretExpired
|
|
554
|
+
if (entry.needsReauth || clientSecretExpired) {
|
|
276
555
|
return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权已过期或刷新失败,请重新授权' };
|
|
277
556
|
}
|
|
557
|
+
if (entry.grant.accessTokenExpiresAt <= Date.now()) {
|
|
558
|
+
return {
|
|
559
|
+
ok: false,
|
|
560
|
+
kind: 'refresh',
|
|
561
|
+
serverKey: record.serverKey,
|
|
562
|
+
serverName: record.serverName,
|
|
563
|
+
message: entry.refreshPromise
|
|
564
|
+
? 'OAuth Access Token 已到期,正在自动刷新'
|
|
565
|
+
: `OAuth 刷新暂时失败,正在自动重试${entry.lastRefreshError ? `:${entry.lastRefreshError}` : ''}`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
278
568
|
return null;
|
|
279
569
|
}
|
|
280
570
|
|
|
571
|
+
function registeredStdioTools(record) {
|
|
572
|
+
if (typeof ctx.tools?.schemas !== 'function') {
|
|
573
|
+
return { supported: false, tools: [] };
|
|
574
|
+
}
|
|
575
|
+
const prefix = `mcp__${record.serverName}__`;
|
|
576
|
+
try {
|
|
577
|
+
const tools = ctx.tools.schemas()
|
|
578
|
+
.filter((schema) => typeof schema?.name === 'string' && schema.name.startsWith(prefix))
|
|
579
|
+
.map((schema) => ({
|
|
580
|
+
name: schema.name.slice(prefix.length),
|
|
581
|
+
publicName: schema.name,
|
|
582
|
+
title: schema.title || schema.name.slice(prefix.length),
|
|
583
|
+
description: schema.description || '',
|
|
584
|
+
}));
|
|
585
|
+
return { supported: true, tools };
|
|
586
|
+
} catch (error) {
|
|
587
|
+
logger.warn(`read registered tools for "${record.serverName}" failed: ${error.message}`);
|
|
588
|
+
return { supported: true, tools: [], error };
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function readinessResults(records, validationResults = []) {
|
|
593
|
+
return records.map((record, index) => {
|
|
594
|
+
const validated = validationResults[index];
|
|
595
|
+
return {
|
|
596
|
+
...(validated ?? {}),
|
|
597
|
+
ok: true,
|
|
598
|
+
kind: 'connected',
|
|
599
|
+
serverKey: record.serverKey,
|
|
600
|
+
serverName: record.serverName,
|
|
601
|
+
message: record.transport === 'stdio'
|
|
602
|
+
? 'Host 已完成 stdio MCP 初始化与首次工具同步'
|
|
603
|
+
: validated?.message,
|
|
604
|
+
};
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function connectionSetupFailure(error, prefix = '连接失败') {
|
|
609
|
+
const record = error?.connectionRecord;
|
|
610
|
+
const classified = classifyConnectionError(error, { transport: record?.transport });
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
message: `${prefix}:${classified.message}。未保存连接,请修正后重试。`,
|
|
614
|
+
detail: {
|
|
615
|
+
kind: classified.kind,
|
|
616
|
+
serverKey: record?.serverKey,
|
|
617
|
+
serverName: record?.serverName,
|
|
618
|
+
...(classified.exitCode !== undefined ? { exitCode: classified.exitCode } : {}),
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
281
623
|
function healthSummary(connectorId, records, results, checkedAt = Date.now()) {
|
|
282
624
|
const enabledRecords = records.filter((record) => record.enabled !== false);
|
|
283
|
-
const availableServers = results.filter((result) => result.ok).length;
|
|
284
|
-
const
|
|
625
|
+
const availableServers = results.filter((result) => result.ok && result.kind !== 'managed').length;
|
|
626
|
+
const pendingServers = results.filter((result) => result.ok && result.kind === 'managed').length;
|
|
627
|
+
const failedServers = results.filter((result) => !result.ok).length;
|
|
285
628
|
const authFailures = results.filter((result) => !result.ok && result.kind === 'auth').length;
|
|
629
|
+
const refreshFailures = results.filter((result) => !result.ok && result.kind === 'refresh').length;
|
|
286
630
|
let connectionState = 'configured';
|
|
287
631
|
if (records.length === 0) connectionState = 'disconnected';
|
|
288
632
|
else if (enabledRecords.length === 0) connectionState = 'disabled';
|
|
289
|
-
else if (failedServers === 0 && availableServers > 0) connectionState = 'healthy';
|
|
290
|
-
else if (availableServers > 0) connectionState = 'degraded';
|
|
633
|
+
else if (failedServers === 0 && pendingServers === 0 && availableServers > 0) connectionState = 'healthy';
|
|
634
|
+
else if (availableServers > 0 && (failedServers > 0 || pendingServers > 0)) connectionState = 'degraded';
|
|
635
|
+
else if (pendingServers > 0 && failedServers === 0) connectionState = 'configured';
|
|
291
636
|
else if (authFailures > 0) connectionState = 'reauth';
|
|
637
|
+
else if (refreshFailures > 0) connectionState = 'recovering';
|
|
292
638
|
else if (failedServers > 0) connectionState = 'unavailable';
|
|
293
639
|
const labels = {
|
|
294
640
|
disconnected: '未连接', configured: '已配置', disabled: '已停用', healthy: '已连接',
|
|
295
|
-
degraded: '部分异常', reauth: '需重新授权', unavailable: '连接异常',
|
|
641
|
+
degraded: '部分异常', reauth: '需重新授权', recovering: '自动重试中', unavailable: '连接异常',
|
|
296
642
|
};
|
|
297
643
|
return {
|
|
298
644
|
connectorId,
|
|
@@ -301,6 +647,7 @@ export async function apply(ctx, config) {
|
|
|
301
647
|
configuredServers: records.length,
|
|
302
648
|
enabledServers: enabledRecords.length,
|
|
303
649
|
availableServers,
|
|
650
|
+
pendingServers,
|
|
304
651
|
failedServers,
|
|
305
652
|
checkedAt,
|
|
306
653
|
results,
|
|
@@ -356,6 +703,156 @@ export async function apply(ctx, config) {
|
|
|
356
703
|
return env;
|
|
357
704
|
}
|
|
358
705
|
|
|
706
|
+
/* ───────────────────────── OAuth Grant 共享 ───────────────────────── */
|
|
707
|
+
|
|
708
|
+
function canonicalIssuer(value) {
|
|
709
|
+
try {
|
|
710
|
+
const url = new URL(value);
|
|
711
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
|
712
|
+
} catch {
|
|
713
|
+
return String(value ?? '').replace(/\/+$/, '');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function oauthResourceOrigins(connector) {
|
|
718
|
+
return [...new Set((connector.servers ?? []).map((server) => {
|
|
719
|
+
try { return new URL(server.url).origin; } catch { return ''; }
|
|
720
|
+
}).filter(Boolean))].sort();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function oauthSharingConnectors(connector) {
|
|
724
|
+
if (connector.auth?.grantSharing !== 'issuer' || !connector.auth.issuer) return [connector];
|
|
725
|
+
const issuer = canonicalIssuer(connector.auth.issuer);
|
|
726
|
+
const resourceOrigins = new Set(oauthResourceOrigins(connector));
|
|
727
|
+
const matches = state.merged.filter((candidate) => (
|
|
728
|
+
candidate.published !== false
|
|
729
|
+
&& candidate.auth?.mode === 'oauth2-pkce'
|
|
730
|
+
&& candidate.auth.grantSharing === 'issuer'
|
|
731
|
+
&& canonicalIssuer(candidate.auth.issuer) === issuer
|
|
732
|
+
&& candidate.auth.scope === connector.auth.scope
|
|
733
|
+
&& candidate.auth.tokenEndpointAuthMethod === connector.auth.tokenEndpointAuthMethod
|
|
734
|
+
// 防止同 issuer 的第三方描述把共享 Bearer Token 聚合到其他资源域名。
|
|
735
|
+
&& oauthResourceOrigins(candidate).every((origin) => resourceOrigins.has(origin))
|
|
736
|
+
));
|
|
737
|
+
// 授权请求的 resource 必须优先使用用户点击的卡片;其余同组 Server 只用于识别共享范围。
|
|
738
|
+
return [connector, ...matches.filter((candidate) => candidate.id !== connector.id)];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function oauthSharingKey(connector) {
|
|
742
|
+
if (connector.auth?.grantSharing !== 'issuer' || !connector.auth.issuer) return `connector:${connector.id}`;
|
|
743
|
+
return `issuer:${config.account}:${canonicalIssuer(connector.auth.issuer)}:${connector.auth.scope}:${connector.auth.tokenEndpointAuthMethod}:${oauthResourceOrigins(connector).join(',')}`;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function oauthAuthorizationDescriptor(connector, sharingConnectors) {
|
|
747
|
+
if (sharingConnectors.length === 1) return connector;
|
|
748
|
+
const seen = new Set();
|
|
749
|
+
const servers = [];
|
|
750
|
+
for (const candidate of sharingConnectors) {
|
|
751
|
+
for (const server of candidate.servers) {
|
|
752
|
+
if (seen.has(server.url)) continue;
|
|
753
|
+
seen.add(server.url);
|
|
754
|
+
servers.push({ ...server, serverKey: `${candidate.id}:${server.serverKey}` });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return { ...connector, servers };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function oauthRecordsFor(connectors, authorizedResources, grantKey) {
|
|
761
|
+
const granted = new Set(authorizedResources);
|
|
762
|
+
const now = Date.now();
|
|
763
|
+
const records = [];
|
|
764
|
+
for (const connector of connectors) {
|
|
765
|
+
for (const server of connector.servers.filter((candidate) => granted.has(candidate.url))) {
|
|
766
|
+
records.push({
|
|
767
|
+
key: `${connector.id}-${server.serverKey}`,
|
|
768
|
+
connectorId: connector.id,
|
|
769
|
+
kind: 'oauth',
|
|
770
|
+
name: connector.servers.length > 1 ? `${connector.name}·${server.serverKey}` : connector.name,
|
|
771
|
+
serverKey: server.serverKey,
|
|
772
|
+
transport: server.transport,
|
|
773
|
+
url: server.url,
|
|
774
|
+
serverName: server.serverName,
|
|
775
|
+
headers: server.headers,
|
|
776
|
+
auth: { mode: 'oauth', grantKey },
|
|
777
|
+
enabled: true,
|
|
778
|
+
createdAt: now,
|
|
779
|
+
updatedAt: now,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return records;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function ensureRequestedConnectorsAuthorized(requestedIds, records) {
|
|
787
|
+
const connectedIds = new Set(records.map((record) => record.connectorId));
|
|
788
|
+
const missing = [...requestedIds].filter((id) => !connectedIds.has(id));
|
|
789
|
+
if (missing.length === 0) return;
|
|
790
|
+
throw new Error(`本次授权未包含连接器所需的 MCP 资源:${missing.join(', ')}`);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function findReusableSharedGrant(connector) {
|
|
794
|
+
if (connector.auth?.grantSharing !== 'issuer' || !connector.auth.issuer) return null;
|
|
795
|
+
const issuer = canonicalIssuer(connector.auth.issuer);
|
|
796
|
+
for (const [grantKey, entry] of state.grants) {
|
|
797
|
+
const grant = entry.grant;
|
|
798
|
+
if (entry.needsReauth
|
|
799
|
+
|| grant.account !== config.account
|
|
800
|
+
|| grant.scope !== connector.auth.scope
|
|
801
|
+
|| (grant.tokenEndpointAuthMethod ?? 'none') !== connector.auth.tokenEndpointAuthMethod
|
|
802
|
+
|| canonicalIssuer(grant.issuer) !== issuer) continue;
|
|
803
|
+
|
|
804
|
+
if (grant.accessTokenExpiresAt <= Date.now()) {
|
|
805
|
+
try {
|
|
806
|
+
await refreshGrantWithRecovery(grantKey, 'shared-connect');
|
|
807
|
+
} catch (error) {
|
|
808
|
+
if (error.refreshFailure?.kind === 'transient') {
|
|
809
|
+
return { temporaryFailure: error.refreshFailure };
|
|
810
|
+
}
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (connector.servers.some((server) => entry.grant.authorizedResources.includes(server.url))) {
|
|
815
|
+
return { grantKey, entry };
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function attachConnectorsToGrant(grantKey, entry, connectors, requestedIds) {
|
|
822
|
+
const usableConnectors = connectors.filter((connector) => {
|
|
823
|
+
const conflict = legacyConflictForConnector(connector);
|
|
824
|
+
if (!conflict) return true;
|
|
825
|
+
warnLegacyConflict(conflict);
|
|
826
|
+
return false;
|
|
827
|
+
});
|
|
828
|
+
const records = oauthRecordsFor(usableConnectors, entry.grant.authorizedResources, grantKey);
|
|
829
|
+
ensureRequestedConnectorsAuthorized(requestedIds, records);
|
|
830
|
+
const connectedConnectorIds = [...new Set(records.map((record) => record.connectorId))];
|
|
831
|
+
const previous = entry.grant;
|
|
832
|
+
const next = {
|
|
833
|
+
...previous,
|
|
834
|
+
connectorIds: [...new Set([...(previous.connectorIds ?? []), ...connectedConnectorIds])],
|
|
835
|
+
updatedAt: Date.now(),
|
|
836
|
+
};
|
|
837
|
+
entry.grant = next;
|
|
838
|
+
if (config.persistSecrets) await grantStore.put(next);
|
|
839
|
+
try {
|
|
840
|
+
const committed = await commitConnectionRecords(records);
|
|
841
|
+
for (const connector of usableConnectors) {
|
|
842
|
+
const own = committed.filter((record) => record.connectorId === connector.id);
|
|
843
|
+
if (own.length > 0) cacheHealth(connector.id, own, readinessResults(own));
|
|
844
|
+
}
|
|
845
|
+
scheduleRefresh(grantKey);
|
|
846
|
+
return committed;
|
|
847
|
+
} catch (error) {
|
|
848
|
+
entry.grant = previous;
|
|
849
|
+
if (config.persistSecrets) await grantStore.put(previous).catch((rollbackError) => {
|
|
850
|
+
logger.error(`shared grant rollback ${grantKey} failed: ${rollbackError.message}`);
|
|
851
|
+
});
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
359
856
|
/* ───────────────────────── api 门面 ───────────────────────── */
|
|
360
857
|
|
|
361
858
|
const api = {
|
|
@@ -415,18 +912,67 @@ export async function apply(ctx, config) {
|
|
|
415
912
|
if (!connector) return { ok: false, message: `连接器 "${connectorId}" 不存在(可先 mcp_connector_refresh_catalog 或 mcp_connector_catalog 查看)` };
|
|
416
913
|
|
|
417
914
|
if (connector.auth.mode === 'oauth2-pkce') {
|
|
418
|
-
const
|
|
419
|
-
if (
|
|
915
|
+
const conflict = legacyConflictForConnector(connector);
|
|
916
|
+
if (conflict) {
|
|
917
|
+
warnLegacyConflict(conflict);
|
|
918
|
+
return { ok: false, message: conflict.message, detail: { kind: 'plugin-conflict', pluginId: conflict.pluginId, serverName: conflict.serverName } };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const sharingKey = oauthSharingKey(connector);
|
|
922
|
+
const inFlight = state.oauthConnects.get(sharingKey);
|
|
923
|
+
if (inFlight) {
|
|
924
|
+
inFlight.requestedConnectorIds.add(connector.id);
|
|
925
|
+
return inFlight.promise;
|
|
926
|
+
}
|
|
927
|
+
const requestedConnectorIds = new Set([connector.id]);
|
|
420
928
|
const promise = (async () => {
|
|
421
929
|
try {
|
|
930
|
+
const sharingConnectors = oauthSharingConnectors(connector);
|
|
931
|
+
const reusable = await findReusableSharedGrant(connector);
|
|
932
|
+
if (reusable?.temporaryFailure) {
|
|
933
|
+
return {
|
|
934
|
+
ok: false,
|
|
935
|
+
message: `OAuth 授权刷新暂时失败(${reusable.temporaryFailure.code}),插件将自动重试,无需重新授权`,
|
|
936
|
+
detail: { kind: 'refresh-retry', retrying: true },
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
if (reusable?.entry) {
|
|
940
|
+
const requestedConnectors = sharingConnectors.filter((item) => requestedConnectorIds.has(item.id));
|
|
941
|
+
const committed = await attachConnectorsToGrant(
|
|
942
|
+
reusable.grantKey,
|
|
943
|
+
reusable.entry,
|
|
944
|
+
requestedConnectors,
|
|
945
|
+
requestedConnectorIds,
|
|
946
|
+
);
|
|
947
|
+
const created = committed.map((record) => record.key);
|
|
948
|
+
return {
|
|
949
|
+
ok: true,
|
|
950
|
+
message: `已复用同账号 OAuth 授权连接 ${requestedConnectors.length} 个连接器(${created.length} 个 MCP server),无需再次授权`,
|
|
951
|
+
detail: { keys: created, grantKey: reusable.grantKey, reusedGrant: true, retiredGrantCount: 0 },
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const sharingConnectorIds = new Set(sharingConnectors.map((item) => item.id));
|
|
422
956
|
const previousGrantKeys = new Set(
|
|
423
957
|
[...state.grants]
|
|
424
|
-
.filter(([, entry]) => entry.grant.connectorIds?.
|
|
958
|
+
.filter(([, entry]) => entry.grant.connectorIds?.some((id) => sharingConnectorIds.has(id)))
|
|
425
959
|
.map(([key]) => key),
|
|
426
960
|
);
|
|
427
|
-
const
|
|
961
|
+
const authorizationConnector = oauthAuthorizationDescriptor(connector, sharingConnectors);
|
|
962
|
+
const authz = await oauthAuthorize({ connector: authorizationConnector, config, logger, signal });
|
|
428
963
|
const grantKey = grantStore.keyFor(config.account, authz.issuer, authz.clientId, authz.scope);
|
|
429
964
|
const entryResource = authz.entryResource;
|
|
965
|
+
const authorizedResources = authz.grantedResources?.length ? authz.grantedResources : [entryResource];
|
|
966
|
+
const alreadyConnectedIds = new Set(
|
|
967
|
+
[...state.connections.values()]
|
|
968
|
+
.filter((record) => sharingConnectorIds.has(record.connectorId))
|
|
969
|
+
.map((record) => record.connectorId),
|
|
970
|
+
);
|
|
971
|
+
const targetIds = new Set([...alreadyConnectedIds, ...requestedConnectorIds]);
|
|
972
|
+
const targetConnectors = sharingConnectors.filter((item) => targetIds.has(item.id));
|
|
973
|
+
const previewRecords = oauthRecordsFor(targetConnectors, authorizedResources, grantKey);
|
|
974
|
+
ensureRequestedConnectorsAuthorized(requestedConnectorIds, previewRecords);
|
|
975
|
+
const grantedConnectorIds = [...new Set(previewRecords.map((record) => record.connectorId))];
|
|
430
976
|
const grant = {
|
|
431
977
|
key: grantKey,
|
|
432
978
|
issuer: authz.issuer,
|
|
@@ -438,62 +984,41 @@ export async function apply(ctx, config) {
|
|
|
438
984
|
scope: authz.scope,
|
|
439
985
|
account: config.account,
|
|
440
986
|
accessToken: authz.token.accessToken,
|
|
441
|
-
accessTokenExpiresAt: Date.now() + authz.token.expiresIn * 1000
|
|
987
|
+
accessTokenExpiresAt: Date.now() + authz.token.expiresIn * 1000,
|
|
442
988
|
refreshToken: authz.token.refreshToken ?? '',
|
|
443
|
-
authorizedResources
|
|
444
|
-
|
|
445
|
-
.map((s) => s.url).length
|
|
446
|
-
? connector.servers.filter((s) => authz.grantedKeys.includes(s.serverKey)).map((s) => s.url)
|
|
447
|
-
: [entryResource],
|
|
448
|
-
connectorIds: [connector.id],
|
|
989
|
+
authorizedResources,
|
|
990
|
+
connectorIds: grantedConnectorIds,
|
|
449
991
|
updatedAt: Date.now(),
|
|
450
992
|
};
|
|
451
|
-
|
|
993
|
+
const runtime = grantRuntime(grant);
|
|
994
|
+
state.grants.set(grantKey, runtime);
|
|
452
995
|
if (config.persistSecrets) await grantStore.put(grant);
|
|
453
|
-
|
|
454
|
-
const created =
|
|
455
|
-
for (const sk of authz.grantedKeys) {
|
|
456
|
-
const server = connector.servers.find((s) => s.serverKey === sk);
|
|
457
|
-
if (!server) continue;
|
|
458
|
-
const record = {
|
|
459
|
-
key: `${connector.id}-${sk}`,
|
|
460
|
-
connectorId: connector.id,
|
|
461
|
-
kind: 'oauth',
|
|
462
|
-
name: connector.servers.length > 1 ? `${connector.name}·${sk}` : connector.name,
|
|
463
|
-
serverKey: sk,
|
|
464
|
-
transport: server.transport,
|
|
465
|
-
url: server.url,
|
|
466
|
-
serverName: server.serverName,
|
|
467
|
-
headers: server.headers,
|
|
468
|
-
auth: { mode: 'oauth', grantKey },
|
|
469
|
-
enabled: true,
|
|
470
|
-
createdAt: Date.now(),
|
|
471
|
-
updatedAt: Date.now(),
|
|
472
|
-
};
|
|
473
|
-
await upsertRecord(record);
|
|
474
|
-
created.push(record.key);
|
|
475
|
-
}
|
|
476
|
-
scheduleRefresh(grantKey);
|
|
996
|
+
const committed = await attachConnectorsToGrant(grantKey, runtime, targetConnectors, requestedConnectorIds);
|
|
997
|
+
const created = committed.map((record) => record.key);
|
|
477
998
|
let retiredGrantCount = 0;
|
|
478
999
|
for (const previousKey of previousGrantKeys) {
|
|
479
1000
|
if (previousKey !== grantKey && await retireGrantIfUnused(previousKey, { revoke: true })) retiredGrantCount += 1;
|
|
480
1001
|
}
|
|
481
1002
|
return {
|
|
482
1003
|
ok: true,
|
|
483
|
-
message:
|
|
484
|
-
|
|
1004
|
+
message: sharingConnectors.length > 1
|
|
1005
|
+
? `OAuth 授权成功;已为同一账号保存共享授权并连接 ${targetConnectors.length} 个连接器(${created.length} 个 MCP server)`
|
|
1006
|
+
: `已连接 "${connector.name}"(${created.length} 个 MCP server),共 ${created.length} 条`,
|
|
1007
|
+
detail: { keys: created, grantKey, sharedGrant: sharingConnectors.length > 1, retiredGrantCount },
|
|
485
1008
|
};
|
|
486
1009
|
} catch (error) {
|
|
487
1010
|
logger.error(`oauth connect failed: ${error.message}`);
|
|
488
1011
|
await pruneOrphanGrants();
|
|
489
|
-
return
|
|
1012
|
+
return error?.connectionRecord
|
|
1013
|
+
? connectionSetupFailure(error)
|
|
1014
|
+
: { ok: false, message: `连接失败: ${error.message}` };
|
|
490
1015
|
}
|
|
491
1016
|
})();
|
|
492
|
-
state.oauthConnects.set(
|
|
1017
|
+
state.oauthConnects.set(sharingKey, { promise, requestedConnectorIds });
|
|
493
1018
|
try {
|
|
494
1019
|
return await promise;
|
|
495
1020
|
} finally {
|
|
496
|
-
if (state.oauthConnects.get(
|
|
1021
|
+
if (state.oauthConnects.get(sharingKey)?.promise === promise) state.oauthConnects.delete(sharingKey);
|
|
497
1022
|
}
|
|
498
1023
|
}
|
|
499
1024
|
|
|
@@ -518,8 +1043,15 @@ export async function apply(ctx, config) {
|
|
|
518
1043
|
createdAt: Date.now(),
|
|
519
1044
|
updatedAt: Date.now(),
|
|
520
1045
|
};
|
|
521
|
-
await
|
|
522
|
-
return {
|
|
1046
|
+
const validation = await validateConnectionRecords([record], { timeoutMs: config.requestTimeoutMs });
|
|
1047
|
+
if (!validation.ok) return { ...validation, detail: { connectorId: connector.id, ...validation.detail } };
|
|
1048
|
+
try {
|
|
1049
|
+
const [committed] = await commitConnectionRecords([record]);
|
|
1050
|
+
cacheHealth(connector.id, [committed], readinessResults([committed], validation.detail.results));
|
|
1051
|
+
return { ok: true, message: `已连接 "${connector.name}"`, detail: { key: committed.key } };
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
return connectionSetupFailure(error);
|
|
1054
|
+
}
|
|
523
1055
|
}
|
|
524
1056
|
|
|
525
1057
|
// bearer / api-key 型:目录不含密钥,引导自定义配置
|
|
@@ -574,12 +1106,9 @@ export async function apply(ctx, config) {
|
|
|
574
1106
|
const validation = await validateConnectionRecords(records, { timeoutMs: config.requestTimeoutMs });
|
|
575
1107
|
if (!validation.ok) return { ...validation, detail: { connectorId: connector.id, ...validation.detail } };
|
|
576
1108
|
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
created.push(record.key);
|
|
581
|
-
}
|
|
582
|
-
cacheHealth(connector.id, records, validation.detail.results);
|
|
1109
|
+
const committed = await commitConnectionRecords(records);
|
|
1110
|
+
const created = committed.map((record) => record.key);
|
|
1111
|
+
cacheHealth(connector.id, committed, readinessResults(committed, validation.detail.results));
|
|
583
1112
|
return {
|
|
584
1113
|
ok: true,
|
|
585
1114
|
message: `已配置并连接 "${connector.name}"(${created.length} 个 MCP Server)`,
|
|
@@ -587,20 +1116,39 @@ export async function apply(ctx, config) {
|
|
|
587
1116
|
};
|
|
588
1117
|
}
|
|
589
1118
|
const record = buildManualRecord(params);
|
|
590
|
-
await
|
|
591
|
-
|
|
1119
|
+
const validation = await validateConnectionRecords([record], { timeoutMs: config.requestTimeoutMs });
|
|
1120
|
+
if (!validation.ok) return validation;
|
|
1121
|
+
const [committed] = await commitConnectionRecords([record]);
|
|
1122
|
+
cacheHealth(committed.connectorId, [committed], readinessResults([committed], validation.detail.results));
|
|
1123
|
+
return { ok: true, message: `已配置并连接 "${committed.name}"(serverName=${committed.serverName})`, detail: { key: committed.key, serverName: committed.serverName } };
|
|
592
1124
|
} catch (error) {
|
|
593
|
-
return
|
|
1125
|
+
return error?.connectionRecord
|
|
1126
|
+
? connectionSetupFailure(error, '配置失败')
|
|
1127
|
+
: { ok: false, message: `配置失败: ${error.message}` };
|
|
594
1128
|
}
|
|
595
1129
|
},
|
|
596
1130
|
|
|
597
1131
|
async importJson(json) {
|
|
598
1132
|
try {
|
|
599
1133
|
const { records, skipped } = normalizeJsonImport(json);
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1134
|
+
const validation = await validateConnectionRecords(records, {
|
|
1135
|
+
timeoutMs: config.requestTimeoutMs,
|
|
1136
|
+
concurrency: 4,
|
|
1137
|
+
});
|
|
1138
|
+
if (!validation.ok) return validation;
|
|
1139
|
+
const committed = await commitConnectionRecords(records);
|
|
1140
|
+
const keys = committed.map((record) => record.key);
|
|
1141
|
+
const grouped = new Map();
|
|
1142
|
+
for (const record of committed) {
|
|
1143
|
+
if (!grouped.has(record.connectorId)) grouped.set(record.connectorId, []);
|
|
1144
|
+
grouped.get(record.connectorId).push(record);
|
|
1145
|
+
}
|
|
1146
|
+
for (const [connectorId, connectorRecords] of grouped) {
|
|
1147
|
+
const results = connectorRecords.map((record) => {
|
|
1148
|
+
const index = committed.findIndex((item) => item.key === record.key);
|
|
1149
|
+
return readinessResults([record], [validation.detail.results[index]])[0];
|
|
1150
|
+
});
|
|
1151
|
+
cacheHealth(connectorId, connectorRecords, results);
|
|
604
1152
|
}
|
|
605
1153
|
const extra = skipped.length ? `;跳过:${skipped.join('、')}` : '';
|
|
606
1154
|
return {
|
|
@@ -609,7 +1157,9 @@ export async function apply(ctx, config) {
|
|
|
609
1157
|
detail: { keys, skipped },
|
|
610
1158
|
};
|
|
611
1159
|
} catch (error) {
|
|
612
|
-
return
|
|
1160
|
+
return error?.connectionRecord
|
|
1161
|
+
? connectionSetupFailure(error, '导入失败')
|
|
1162
|
+
: { ok: false, message: `导入失败: ${error.message}` };
|
|
613
1163
|
}
|
|
614
1164
|
},
|
|
615
1165
|
|
|
@@ -651,6 +1201,9 @@ export async function apply(ctx, config) {
|
|
|
651
1201
|
grantKey: record.auth.grantKey,
|
|
652
1202
|
accessTokenExpiresAt: g.grant.accessTokenExpiresAt,
|
|
653
1203
|
needsReauth: !!g.needsReauth,
|
|
1204
|
+
refreshFailureKind: g.refreshFailureKind,
|
|
1205
|
+
lastRefreshError: g.lastRefreshError,
|
|
1206
|
+
refreshRetryAt: g.refreshRetryAt,
|
|
654
1207
|
}
|
|
655
1208
|
: { grantKey: record.auth.grantKey, missing: true };
|
|
656
1209
|
}
|
|
@@ -667,7 +1220,9 @@ export async function apply(ctx, config) {
|
|
|
667
1220
|
authMode: record.auth?.mode ?? 'none',
|
|
668
1221
|
grant: grantInfo,
|
|
669
1222
|
lastError: record.lastError ?? null,
|
|
670
|
-
connectionState: recordHealth?.ok
|
|
1223
|
+
connectionState: recordHealth?.ok && recordHealth.kind !== 'managed'
|
|
1224
|
+
? 'healthy'
|
|
1225
|
+
: recordHealth ? health.connectionState : health?.connectionState ?? 'configured',
|
|
671
1226
|
healthCheckedAt: health?.checkedAt ?? null,
|
|
672
1227
|
healthMessage: recordHealth?.message ?? null,
|
|
673
1228
|
});
|
|
@@ -694,7 +1249,29 @@ export async function apply(ctx, config) {
|
|
|
694
1249
|
for (const record of enabledRecords) {
|
|
695
1250
|
const failure = grantFailure(record);
|
|
696
1251
|
if (failure) immediateResults.push(failure);
|
|
697
|
-
else
|
|
1252
|
+
else if (record.transport === 'stdio') {
|
|
1253
|
+
const registered = registeredStdioTools(record);
|
|
1254
|
+
if (registered.tools.length > 0) {
|
|
1255
|
+
immediateResults.push({
|
|
1256
|
+
ok: true,
|
|
1257
|
+
kind: 'connected',
|
|
1258
|
+
serverKey: record.serverKey,
|
|
1259
|
+
serverName: record.serverName,
|
|
1260
|
+
toolCount: registered.tools.length,
|
|
1261
|
+
message: `Host 已注册 ${registered.tools.length} 个工具`,
|
|
1262
|
+
});
|
|
1263
|
+
} else {
|
|
1264
|
+
immediateResults.push({
|
|
1265
|
+
ok: true,
|
|
1266
|
+
kind: 'managed',
|
|
1267
|
+
serverKey: record.serverKey,
|
|
1268
|
+
serverName: record.serverName,
|
|
1269
|
+
message: registered.supported
|
|
1270
|
+
? 'Host 尚未注册该 stdio Server 的工具'
|
|
1271
|
+
: '当前 Host 无法读取 stdio 工具注册状态',
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
} else checkable.push(record);
|
|
698
1275
|
}
|
|
699
1276
|
let checkedResults = [];
|
|
700
1277
|
if (checkable.length > 0) {
|
|
@@ -708,7 +1285,7 @@ export async function apply(ctx, config) {
|
|
|
708
1285
|
summaries.push(cacheHealth(id, records, [...immediateResults, ...checkedResults]));
|
|
709
1286
|
}
|
|
710
1287
|
const healthy = summaries.filter((item) => item.connectionState === 'healthy').length;
|
|
711
|
-
const attention = summaries.filter((item) => ['reauth', 'degraded', 'unavailable'].includes(item.connectionState)).length;
|
|
1288
|
+
const attention = summaries.filter((item) => ['reauth', 'recovering', 'degraded', 'unavailable'].includes(item.connectionState)).length;
|
|
712
1289
|
return {
|
|
713
1290
|
ok: true,
|
|
714
1291
|
message: targetIds.length === 0
|
|
@@ -741,24 +1318,34 @@ export async function apply(ctx, config) {
|
|
|
741
1318
|
if (plan.summary.alreadyMigrated) { skipped.push(`${plan.summary.connectorName}(已迁移)`); continue; }
|
|
742
1319
|
const grantKey = grantStore.keyFor(config.account, plan.candidate.grant.issuer, plan.candidate.grant.clientId, plan.candidate.grant.scope);
|
|
743
1320
|
const grant = toConnectorGrant(plan.candidate, { key: grantKey, account: config.account, connectorIds: [plan.connector.id] });
|
|
744
|
-
state.grants.set(grantKey,
|
|
1321
|
+
state.grants.set(grantKey, grantRuntime(grant));
|
|
745
1322
|
if (config.persistSecrets) await grantStore.put(grant);
|
|
746
1323
|
|
|
747
1324
|
let enabled = true;
|
|
1325
|
+
let provisionNow = true;
|
|
748
1326
|
let lastError;
|
|
1327
|
+
const legacyConflict = legacyConflictForConnector(plan.connector);
|
|
1328
|
+
if (legacyConflict) {
|
|
1329
|
+
warnLegacyConflict(legacyConflict);
|
|
1330
|
+
provisionNow = false;
|
|
1331
|
+
lastError = `${legacyConflict.message};授权已复制,重启后将由 MCP连接器恢复`;
|
|
1332
|
+
}
|
|
749
1333
|
if (grant.accessTokenExpiresAt <= Date.now()) {
|
|
750
|
-
try { await
|
|
1334
|
+
try { await refreshGrantWithRecovery(grantKey, 'legacy-migration'); }
|
|
751
1335
|
catch (error) {
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
1336
|
+
const permanent = error.refreshFailure?.permanent === true;
|
|
1337
|
+
enabled = !permanent;
|
|
1338
|
+
provisionNow = false;
|
|
1339
|
+
lastError = permanent
|
|
1340
|
+
? `旧授权已失效,请重新连接:${error.refreshFailure?.message ?? error.message}`
|
|
1341
|
+
: `旧授权刷新暂时失败,正在自动重试:${error.refreshFailure?.message ?? error.message}`;
|
|
755
1342
|
}
|
|
756
1343
|
} else {
|
|
757
1344
|
scheduleRefresh(grantKey);
|
|
758
1345
|
}
|
|
759
1346
|
const records = toConnectionRecords(plan, grantKey, { enabled, lastError });
|
|
760
1347
|
for (const record of records) {
|
|
761
|
-
if (enabled) await upsertRecord(record);
|
|
1348
|
+
if (enabled && provisionNow) await upsertRecord(record);
|
|
762
1349
|
else await persistRecord(record);
|
|
763
1350
|
}
|
|
764
1351
|
migrated.push({ connectorId: plan.connector.id, keys: records.map((record) => record.key), enabled });
|
|
@@ -775,11 +1362,26 @@ export async function apply(ctx, config) {
|
|
|
775
1362
|
async setEnabled(key, enabled) {
|
|
776
1363
|
const record = state.connections.get(key);
|
|
777
1364
|
if (!record) return { ok: false, message: `连接 "${key}" 不存在` };
|
|
778
|
-
record
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1365
|
+
const next = { ...record, enabled: !!enabled, updatedAt: Date.now() };
|
|
1366
|
+
if (next.enabled) {
|
|
1367
|
+
try {
|
|
1368
|
+
const [committed] = await commitConnectionRecords([next]);
|
|
1369
|
+
if (committed.connectorId) cacheHealth(committed.connectorId, [committed], readinessResults([committed]));
|
|
1370
|
+
return { ok: true, message: `已启用 "${committed.name}"` };
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
return connectionSetupFailure(error, '启用失败');
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
await disable(ctx, config, next);
|
|
1376
|
+
try {
|
|
1377
|
+
await persistRecord(next);
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
await provision(ctx, config, record, grantMap(), { failOnStartupError: false }).catch((rollbackError) => {
|
|
1380
|
+
logger.error(`disable rollback "${record.key}" failed: ${rollbackError.message}`);
|
|
1381
|
+
});
|
|
1382
|
+
throw error;
|
|
1383
|
+
}
|
|
1384
|
+
return { ok: true, message: `已停用 "${next.name}"` };
|
|
783
1385
|
},
|
|
784
1386
|
|
|
785
1387
|
async disconnect(key, signal) {
|
|
@@ -887,19 +1489,30 @@ export async function apply(ctx, config) {
|
|
|
887
1489
|
const servers = [];
|
|
888
1490
|
for (const record of records) {
|
|
889
1491
|
if (record.transport === 'stdio') {
|
|
1492
|
+
const registered = registeredStdioTools(record);
|
|
890
1493
|
const snapshot = (connector.toolsSnapshot ?? []).find((item) =>
|
|
891
1494
|
item.serverKey === record.serverKey || item.serverName === record.serverName);
|
|
1495
|
+
const liveTools = registered.tools;
|
|
892
1496
|
servers.push({
|
|
893
1497
|
serverKey: record.serverKey || record.name,
|
|
894
1498
|
serverName: record.serverName,
|
|
895
|
-
ok:
|
|
1499
|
+
ok: liveTools.length > 0,
|
|
896
1500
|
managed: true,
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1501
|
+
live: liveTools.length > 0,
|
|
1502
|
+
preview: liveTools.length === 0 && Boolean(snapshot),
|
|
1503
|
+
tools: liveTools.length > 0
|
|
1504
|
+
? liveTools
|
|
1505
|
+
: (snapshot?.tools ?? []).map((tool) => ({
|
|
1506
|
+
name: tool.name,
|
|
1507
|
+
title: tool.title || tool.name,
|
|
1508
|
+
description: tool.description || '',
|
|
1509
|
+
})),
|
|
1510
|
+
...(liveTools.length === 0 ? {
|
|
1511
|
+
errorKind: registered.supported ? 'startup' : 'host-version',
|
|
1512
|
+
error: registered.supported
|
|
1513
|
+
? 'Host 尚未注册该 stdio Server 的工具,请检查启动日志后重试'
|
|
1514
|
+
: '当前 DSH Host 不支持读取工具注册状态,请升级 Host 后重试',
|
|
1515
|
+
} : {}),
|
|
903
1516
|
});
|
|
904
1517
|
continue;
|
|
905
1518
|
}
|
|
@@ -938,7 +1551,7 @@ export async function apply(ctx, config) {
|
|
|
938
1551
|
const totalTools = servers.reduce((sum, s) => sum + (s.tools?.length || 0), 0);
|
|
939
1552
|
const availableServers = servers.filter((server) => server.ok).length;
|
|
940
1553
|
const failedServers = servers.length - availableServers;
|
|
941
|
-
const
|
|
1554
|
+
const pendingManagedServers = servers.filter((server) => server.managed && !server.ok).length;
|
|
942
1555
|
const health = cacheHealth(connectorId, records, servers.map((server) => server.ok
|
|
943
1556
|
? { ok: true, kind: 'connected', serverKey: server.serverKey, serverName: server.serverName }
|
|
944
1557
|
: { ok: false, kind: server.errorKind ?? 'network', serverKey: server.serverKey, serverName: server.serverName, message: server.error }));
|
|
@@ -948,10 +1561,16 @@ export async function apply(ctx, config) {
|
|
|
948
1561
|
ok: availableServers > 0,
|
|
949
1562
|
message: failedServers > 0
|
|
950
1563
|
? `工具加载失败:${failureSummary}${failedServers > 3 ? `;另有 ${failedServers - 3} 个 Server 不可用` : ''}${availableServers > 0 ? `;${availableServers} 个 Server 仍可用` : ''}`
|
|
951
|
-
:
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1564
|
+
: `共 ${servers.length} 个 server,${totalTools} 个工具`,
|
|
1565
|
+
detail: {
|
|
1566
|
+
connectorId,
|
|
1567
|
+
servers,
|
|
1568
|
+
totalTools,
|
|
1569
|
+
availableServers,
|
|
1570
|
+
failedServers,
|
|
1571
|
+
pendingManagedServers,
|
|
1572
|
+
connectionState: health.connectionState,
|
|
1573
|
+
},
|
|
955
1574
|
};
|
|
956
1575
|
},
|
|
957
1576
|
};
|
|
@@ -959,6 +1578,7 @@ export async function apply(ctx, config) {
|
|
|
959
1578
|
/* ───────────────────────── 启动恢复 ───────────────────────── */
|
|
960
1579
|
|
|
961
1580
|
await loadCatalog();
|
|
1581
|
+
detectLegacyPluginConflicts();
|
|
962
1582
|
|
|
963
1583
|
for (const [key, raw] of await connectionStore.entries().catch(() => [])) {
|
|
964
1584
|
try {
|
|
@@ -970,7 +1590,7 @@ export async function apply(ctx, config) {
|
|
|
970
1590
|
}
|
|
971
1591
|
for (const [key, raw] of await grantStore.entries().catch(() => [])) {
|
|
972
1592
|
try {
|
|
973
|
-
state.grants.set(key,
|
|
1593
|
+
state.grants.set(key, grantRuntime(raw));
|
|
974
1594
|
} catch (error) {
|
|
975
1595
|
logger.warn(`skip invalid stored grant "${key}": ${error.message}`);
|
|
976
1596
|
}
|
|
@@ -982,16 +1602,19 @@ export async function apply(ctx, config) {
|
|
|
982
1602
|
for (const [grantKey, g] of state.grants) {
|
|
983
1603
|
if (g.grant.accessTokenExpiresAt <= Date.now()) {
|
|
984
1604
|
try {
|
|
985
|
-
await
|
|
986
|
-
} catch {
|
|
987
|
-
g.needsReauth = true;
|
|
988
|
-
}
|
|
1605
|
+
await refreshGrantWithRecovery(grantKey, 'startup');
|
|
1606
|
+
} catch {}
|
|
989
1607
|
} else {
|
|
990
1608
|
scheduleRefresh(grantKey);
|
|
991
1609
|
}
|
|
992
1610
|
}
|
|
993
1611
|
for (const [key, record] of state.connections) {
|
|
994
1612
|
if (record.enabled !== false) {
|
|
1613
|
+
const failure = grantFailure(record);
|
|
1614
|
+
if (failure) {
|
|
1615
|
+
logger.warn(`skip provision "${key}" at startup: ${failure.message}`);
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
995
1618
|
await provision(ctx, config, record, grantMap()).catch((error) => {
|
|
996
1619
|
logger.warn(`provision "${key}" failed: ${error.message}`);
|
|
997
1620
|
});
|