dsh-mcp-connector 0.2.22 → 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 +15 -1
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/docs/USER-GUIDE.md +3 -0
- package/lib/connectors/oauth-connector.js +10 -2
- package/lib/constants.js +3 -0
- package/lib/grant-lifecycle.js +47 -0
- package/lib/index.js +444 -69
- package/package.json +2 -2
- package/ui/index.html +5 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.23] - 2026-08-26
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- OAuth Grant 现在保存 Access Token 的真实过期时间,并在提前刷新遇到网络、发现端点或服务端暂时故障时采用有上限的指数退避自动恢复;只有 `invalid_grant`、`invalid_client` 等明确不可恢复错误才显示“需重新授权”。
|
|
12
|
+
- 启动恢复不再吞掉 Token 刷新异常;脱敏日志会记录阶段、OAuth 错误码、HTTP 状态与永久/暂时分类,且不会输出 Access Token、Refresh Token 或客户端密钥。
|
|
13
|
+
- `grantSharing: "issuer"` 正式生效:同账号、同 issuer/scope 的卡片共享动态客户端与 Grant,后续连接同组卡片不再重复打开授权页;重新授权会把同组已连接卡片迁移到新 Grant。
|
|
14
|
+
- 启动和连接前检测仍启用的 `qcc-mcp-oauth` / `qcc-legal-mcp-oauth` 及其残留 mcp-client 条目;发现同名 Server 时阻断新连接,避免旧 Token 覆盖新连接器凭据。
|
|
15
|
+
|
|
16
|
+
### Verification
|
|
17
|
+
|
|
18
|
+
- 新增 OAuth 刷新失败分类/脱敏/退避、暂时故障自动恢复、永久失效重新授权、同 issuer 顺序与并发共享授权、旧插件冲突阻断回归测试。
|
|
19
|
+
|
|
7
20
|
## [0.2.22] - 2026-08-26
|
|
8
21
|
|
|
9
22
|
### Fixed
|
|
@@ -372,7 +385,8 @@
|
|
|
372
385
|
- 外部 URL 与导入 Header 执行安全校验。
|
|
373
386
|
- iframe 消息校验同源和消息来源。
|
|
374
387
|
|
|
375
|
-
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.
|
|
388
|
+
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.23...HEAD
|
|
389
|
+
[0.2.23]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.22...v0.2.23
|
|
376
390
|
[0.2.22]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.21...v0.2.22
|
|
377
391
|
[0.2.21]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.20...v0.2.21
|
|
378
392
|
[0.2.20]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.19...v0.2.20
|
package/README.en.md
CHANGED
|
@@ -25,10 +25,10 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
|
|
|
25
25
|
- Credential and MCP initialize validation before HTTP API-key connectors are saved as installed, plus declarative multi-field credential-to-env bindings for marketplace stdio connectors.
|
|
26
26
|
- Dynamic tool discovery grouped by MCP server, including descriptions, search, batched rendering, and an independent scroll region.
|
|
27
27
|
- Curated prompt templates that can open a DSH conversation and prefill its draft; missing variables are requested before the prompt is sent.
|
|
28
|
-
- Persistent connection lifecycle management: restore on restart, enable/disable, disconnect,
|
|
28
|
+
- Persistent connection lifecycle management: restore on restart, enable/disable, disconnect, retry transient OAuth refresh failures with bounded backoff, and revoke authorization. Cards declaring issuer-level sharing reuse one grant; DCR client secrets remain local to that grant.
|
|
29
29
|
- Built-in, remote, and local catalogs with `published` and `featured` controls.
|
|
30
30
|
- A standalone remote Registry, allowing new marketplace cards to appear after refresh without publishing a new npm version.
|
|
31
|
-
- Explicit, non-destructive migration
|
|
31
|
+
- Explicit, non-destructive migration from the two earlier Qichacha OAuth plugins, plus active-plugin conflict detection that blocks duplicate server management and credential overwrites.
|
|
32
32
|
|
|
33
33
|
<!-- catalog-stats:start -->
|
|
34
34
|
As of 2026-08-25, the public Registry publishes 78 connector descriptors. After merging and deduplicating them with the 4 bundled Qichacha cards, the Marketplace exposes 82 cards across 9 business categories. Recommendations remain limited to the four Qichacha cards, PKULaw, and Wind, for 6 featured cards in total. The Registry evolves independently; the badge shown after a client refresh and the live badges above are the authoritative current counts.
|
package/README.md
CHANGED
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
- Prompt 模板:使用 `{{company}}` 等变量,发送前填写真实查询主体。
|
|
26
26
|
- 三种接入:OAuth 2.0 PKCE、自定义 HTTP/stdio、导入 `mcpServers` JSON;也支持从连接器描述 URL 安装。OAuth 动态注册兼容公共客户端以及 `client_secret_post` / `client_secret_basic` 机密客户端。
|
|
27
27
|
- 市场 Bearer/API Key 连接器先执行 MCP initialize 连通性与凭据校验,全部 HTTP Server 通过后才持久化凭据并进入“已安装”;stdio 卡片可声明多个本机凭据字段及其环境变量映射。
|
|
28
|
-
- 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth
|
|
28
|
+
- 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth 自动刷新/退避恢复与撤销;同 issuer 卡片可共享一次授权,DCR 返回的客户端密钥与 Token 一同只保存在本机。
|
|
29
29
|
- 目录运营:内置目录、远程 registry、本地覆盖,支持 `published` 上下架与 `featured` 精选。
|
|
30
30
|
- 独立远程 Registry:新市场卡片合并后客户端刷新即可见,无需重新发布 npm;远程不可用时自动回退内置目录。
|
|
31
31
|
- Registry 工具链:Schema/唯一性/密钥审计、MCP/OAuth 无凭据探针、每周健康巡检。
|
|
32
|
-
- 平滑迁移:显式扫描并复制两个旧企查查 OAuth
|
|
32
|
+
- 平滑迁移:显式扫描并复制两个旧企查查 OAuth 插件授权;检测到旧插件仍启用并管理同名 Server 时阻断重复连接,避免凭据相互覆盖。
|
|
33
33
|
- 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`health_check`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
|
|
34
34
|
|
|
35
35
|
<!-- catalog-stats:start -->
|
package/docs/USER-GUIDE.md
CHANGED
|
@@ -63,6 +63,7 @@ dsh web
|
|
|
63
63
|
| `连接` | OAuth 或免密连接器尚未连接 | 点击后按页面提示完成授权或连通性检查 |
|
|
64
64
|
| `配置` | 需要 Bearer Token 或 API Key | 录入服务商签发的凭据并验证 |
|
|
65
65
|
| `需重新授权` | OAuth 授权过期、被撤销或不可用 | 点击后重新完成 OAuth 授权 |
|
|
66
|
+
| `自动重试中` | OAuth Token 刷新遇到网络或服务端暂时故障 | 无需重新授权;保持 DSH 运行,插件会按退避策略自动恢复 |
|
|
66
67
|
| `已配置` | 本机已有配置,尚未完成本轮健康确认 | 点击“刷新”或进入详情检查 |
|
|
67
68
|
| `已连接` | 最近一次健康检查通过 | 可直接在会话中使用对应 MCP 工具 |
|
|
68
69
|
| `部分异常` / `连接异常` | 一个或多个 Server 未通过检查 | 打开详情查看提示,核对网络、权限和服务状态 |
|
|
@@ -73,6 +74,8 @@ stdio 市场卡片也可能显示一个或多个凭据字段,例如 API Token
|
|
|
73
74
|
|
|
74
75
|
OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据发现。动态客户端注册既支持无需客户端密钥的 `none`,也支持服务商签发密钥的 `client_secret_post` 与 `client_secret_basic`。客户端密钥仅与 OAuth Grant 一同保存在 DSH 本机,用于换取、刷新和撤销 Token;插件不会要求用户把 OAuth Token 或客户端密钥复制到聊天中。
|
|
75
76
|
|
|
77
|
+
当市场描述声明 `grantSharing: "issuer"` 时,同一账号下相同 issuer、scope 和客户端鉴权方式的卡片共享一组 Grant。首次授权仍只启用用户点击的卡片;之后连接同组卡片会直接复用现有授权,不再重复打开 OAuth 页面。网络、OAuth 元数据发现或服务端 5xx 等暂时故障只进入“自动重试中”,明确收到 Refresh Token/客户端失效错误时才进入“需重新授权”。
|
|
78
|
+
|
|
76
79
|
## 5. 查看详情、Prompt 与工具
|
|
77
80
|
|
|
78
81
|
点击卡片或“详情”可打开连接器详情:
|
|
@@ -80,12 +80,19 @@ export async function oauthAuthorize({ connector, config, logger, signal }) {
|
|
|
80
80
|
// 按 token 实际授权范围过滤 server;非 JWT / 无 resource claim 时 fallback 全部
|
|
81
81
|
const grantedUrls = extractTokenResources(token.accessToken);
|
|
82
82
|
let grantedKeys;
|
|
83
|
+
let grantedResources;
|
|
83
84
|
if (grantedUrls) {
|
|
84
85
|
const granted = new Set(grantedUrls);
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
const grantedServers = connector.servers.filter((s) => granted.has(s.url));
|
|
87
|
+
grantedKeys = grantedServers.map((s) => s.serverKey);
|
|
88
|
+
grantedResources = grantedServers.map((s) => s.url);
|
|
89
|
+
if (grantedKeys.length === 0) {
|
|
90
|
+
grantedKeys = [entryServer.serverKey];
|
|
91
|
+
grantedResources = [entryServer.url];
|
|
92
|
+
}
|
|
87
93
|
} else {
|
|
88
94
|
grantedKeys = connector.servers.map((s) => s.serverKey);
|
|
95
|
+
grantedResources = connector.servers.map((s) => s.url);
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
return {
|
|
@@ -98,6 +105,7 @@ export async function oauthAuthorize({ connector, config, logger, signal }) {
|
|
|
98
105
|
scope: connector.auth.scope,
|
|
99
106
|
token,
|
|
100
107
|
grantedKeys,
|
|
108
|
+
grantedResources,
|
|
101
109
|
entryResource,
|
|
102
110
|
};
|
|
103
111
|
}
|
package/lib/constants.js
CHANGED
|
@@ -30,6 +30,9 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
|
30
30
|
/** 首次启动 MCP Server 并完成 initialize + tools/list 的最长等待时间 */
|
|
31
31
|
export const DEFAULT_STARTUP_TIMEOUT_MS = 120_000;
|
|
32
32
|
export const DEFAULT_REFRESH_SKEW_MS = 300_000;
|
|
33
|
+
/** OAuth 刷新暂时失败后的指数退避范围。 */
|
|
34
|
+
export const DEFAULT_REFRESH_RETRY_BASE_MS = 30_000;
|
|
35
|
+
export const DEFAULT_REFRESH_RETRY_MAX_MS = 300_000;
|
|
33
36
|
export const DEFAULT_CATALOG_TTL_MS = 3_600_000;
|
|
34
37
|
|
|
35
38
|
/** PKCE 约束(RFC 7636) */
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** OAuth Grant 刷新失败分类、日志脱敏与重试退避。 */
|
|
2
|
+
|
|
3
|
+
const PERMANENT_REFRESH_CODES = new Set([
|
|
4
|
+
'invalid_grant',
|
|
5
|
+
'invalid_token',
|
|
6
|
+
'invalid_client',
|
|
7
|
+
'unauthorized_client',
|
|
8
|
+
'invalid_scope',
|
|
9
|
+
'client_secret_expired',
|
|
10
|
+
'missing_refresh_token',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
/** 防止 OAuth 服务端错误描述意外回显凭据。 */
|
|
14
|
+
export function redactOAuthDetail(value) {
|
|
15
|
+
return String(value ?? '')
|
|
16
|
+
.replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
|
|
17
|
+
.replace(/([?&](?:access_token|refresh_token|client_secret|code)=)[^&#\s]*/gi, '$1[REDACTED]')
|
|
18
|
+
.replace(/((?:access_token|refresh_token|client_secret)\s*[=:]\s*)[^\s,;&}]+/gi, '$1[REDACTED]')
|
|
19
|
+
.slice(0, 500);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 只有明确表示 Refresh Token / 动态客户端不可恢复的 OAuth 错误才要求重新授权。
|
|
24
|
+
* 网络、发现端点、5xx、超时和未知服务端错误都按暂时故障自动重试。
|
|
25
|
+
*/
|
|
26
|
+
export function classifyRefreshFailure(error) {
|
|
27
|
+
const code = typeof error?.code === 'string' && error.code
|
|
28
|
+
? redactOAuthDetail(error.code).slice(0, 80)
|
|
29
|
+
: error?.name === 'OAuthNetworkError' ? 'network_error' : 'refresh_failed';
|
|
30
|
+
const httpStatus = Number.isInteger(error?.httpStatus) ? error.httpStatus : undefined;
|
|
31
|
+
const permanent = PERMANENT_REFRESH_CODES.has(code) || httpStatus === 401 || httpStatus === 403;
|
|
32
|
+
return {
|
|
33
|
+
permanent,
|
|
34
|
+
kind: permanent ? 'permanent' : 'transient',
|
|
35
|
+
code,
|
|
36
|
+
httpStatus,
|
|
37
|
+
message: redactOAuthDetail(error?.message || code),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 指数退避:base、2×、4×……,封顶 max。failureCount 从 1 开始。 */
|
|
42
|
+
export function refreshRetryDelay(failureCount, baseMs, maxMs) {
|
|
43
|
+
const base = Math.max(1_000, Number(baseMs) || 30_000);
|
|
44
|
+
const max = Math.max(base, Number(maxMs) || 300_000);
|
|
45
|
+
const exponent = Math.max(0, Math.min(20, Number(failureCount || 1) - 1));
|
|
46
|
+
return Math.min(max, base * (2 ** exponent));
|
|
47
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
18
18
|
DEFAULT_STARTUP_TIMEOUT_MS,
|
|
19
19
|
DEFAULT_REFRESH_SKEW_MS,
|
|
20
|
+
DEFAULT_REFRESH_RETRY_BASE_MS,
|
|
21
|
+
DEFAULT_REFRESH_RETRY_MAX_MS,
|
|
20
22
|
DEFAULT_CATALOG_TTL_MS,
|
|
21
23
|
} from './constants.js';
|
|
22
24
|
import { defineConnectorDomain, ConnectionStore, GrantStore, CatalogStore } from './stores.js';
|
|
@@ -25,7 +27,7 @@ import { loadBundledCatalog, fetchRemoteCatalogWithFallback, mergeCatalog, listC
|
|
|
25
27
|
import { oauthAuthorize } from './connectors/oauth-connector.js';
|
|
26
28
|
import { buildManualRecord } from './connectors/manual-connector.js';
|
|
27
29
|
import { normalizeJsonImport } from './connectors/json-connector.js';
|
|
28
|
-
import { discoverServerMetadata, refreshAccessToken, revokeRefreshToken } from './oauth.js';
|
|
30
|
+
import { discoverServerMetadata, refreshAccessToken, revokeRefreshToken, extractTokenResources } from './oauth.js';
|
|
29
31
|
import { provision, disable, remove, entryIdFor } from './mcp-provision.js';
|
|
30
32
|
import { classifyConnectionError, validateConnectionRecords } from './mcp-validation.js';
|
|
31
33
|
import { listMcpTools, McpHttpError } from './mcp-http.js';
|
|
@@ -33,9 +35,21 @@ import { registerTools } from './tools.js';
|
|
|
33
35
|
import { mountWebRoutes } from './web.js';
|
|
34
36
|
import { assertSafeUrl } from './util.js';
|
|
35
37
|
import { readLegacyGrantCandidates, planLegacyMigration, toConnectorGrant, toConnectionRecords } from './migration.js';
|
|
38
|
+
import { classifyRefreshFailure, refreshRetryDelay } from './grant-lifecycle.js';
|
|
36
39
|
|
|
37
40
|
export const name = 'mcp-connector';
|
|
38
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
|
+
|
|
39
53
|
export const inject = ['tools', 'storageDomain', 'loader'];
|
|
40
54
|
|
|
41
55
|
export const Config = z.object({
|
|
@@ -57,6 +71,10 @@ export const Config = z.object({
|
|
|
57
71
|
startupTimeoutMs: z.number().default(DEFAULT_STARTUP_TIMEOUT_MS),
|
|
58
72
|
/** 提前刷新阈值(ms) */
|
|
59
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),
|
|
60
78
|
/** 是否自动打开浏览器(false = 仅打印授权 URL) */
|
|
61
79
|
openBrowser: z.boolean().default(true),
|
|
62
80
|
/** 授权账号标识(预留多账号) */
|
|
@@ -81,9 +99,10 @@ export async function apply(ctx, config) {
|
|
|
81
99
|
dynamic: new Map(), // URL 安装的临时连接器
|
|
82
100
|
overrides: new Map(), // id -> { published?, featured? }
|
|
83
101
|
connections: new Map(), // key -> ConnectionRecord
|
|
84
|
-
grants: new Map(), // grantKey -> { grant, timer, refreshPromise, needsReauth }
|
|
102
|
+
grants: new Map(), // grantKey -> { grant, timer, refreshPromise, needsReauth, refreshFailure* }
|
|
85
103
|
health: new Map(), // connectorId -> 最近一次主动连通性检查摘要(不持久化)
|
|
86
|
-
oauthConnects: new Map(), //
|
|
104
|
+
oauthConnects: new Map(), // sharingKey -> { promise, requestedConnectorIds }(防同 issuer 重复弹窗)
|
|
105
|
+
warnedLegacyConflicts: new Set(),
|
|
87
106
|
};
|
|
88
107
|
|
|
89
108
|
/* ───────────────────────── 目录加载 ───────────────────────── */
|
|
@@ -154,8 +173,91 @@ export async function apply(ctx, config) {
|
|
|
154
173
|
await recomputeMerged();
|
|
155
174
|
}
|
|
156
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
|
+
|
|
157
246
|
/* ───────────────────────── grant 刷新 ───────────────────────── */
|
|
158
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
|
+
|
|
159
261
|
function grantMap() {
|
|
160
262
|
const map = new Map();
|
|
161
263
|
for (const [key, g] of state.grants) map.set(key, g.grant);
|
|
@@ -164,10 +266,16 @@ export async function apply(ctx, config) {
|
|
|
164
266
|
|
|
165
267
|
async function refreshGrant(grantKey) {
|
|
166
268
|
const entry = state.grants.get(grantKey);
|
|
167
|
-
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
|
+
}
|
|
168
274
|
if (entry.grant.clientSecret && entry.grant.clientSecretExpiresAt > 0
|
|
169
275
|
&& entry.grant.clientSecretExpiresAt * 1000 <= Date.now()) {
|
|
170
|
-
|
|
276
|
+
const error = new Error('OAuth dynamic client secret expired');
|
|
277
|
+
error.code = 'client_secret_expired';
|
|
278
|
+
throw error;
|
|
171
279
|
}
|
|
172
280
|
if (entry.refreshPromise) return entry.refreshPromise;
|
|
173
281
|
entry.refreshPromise = (async () => {
|
|
@@ -177,22 +285,39 @@ export async function apply(ctx, config) {
|
|
|
177
285
|
clientSecret: entry.grant.clientSecret,
|
|
178
286
|
tokenEndpointAuthMethod: entry.grant.tokenEndpointAuthMethod ?? 'none',
|
|
179
287
|
refreshToken: entry.grant.refreshToken,
|
|
180
|
-
resource
|
|
288
|
+
// 多资源共享 Grant 不应在刷新时被第一个 resource 意外收窄。
|
|
289
|
+
resource: entry.grant.authorizedResources.length === 1 ? entry.grant.authorizedResources[0] : undefined,
|
|
181
290
|
scope: entry.grant.scope,
|
|
182
291
|
timeoutMs: config.requestTimeoutMs,
|
|
183
292
|
});
|
|
293
|
+
const refreshedResources = extractTokenResources(token.accessToken);
|
|
184
294
|
const next = {
|
|
185
295
|
...entry.grant,
|
|
186
296
|
accessToken: token.accessToken,
|
|
187
|
-
|
|
297
|
+
// 持久化真实过期时间;提前刷新只用于定时调度,不能冒充 Token 已过期。
|
|
298
|
+
accessTokenExpiresAt: Date.now() + token.expiresIn * 1000,
|
|
188
299
|
refreshToken: token.refreshToken ?? entry.grant.refreshToken,
|
|
300
|
+
authorizedResources: refreshedResources?.length ? refreshedResources : entry.grant.authorizedResources,
|
|
301
|
+
updatedAt: Date.now(),
|
|
189
302
|
};
|
|
190
303
|
entry.grant = next;
|
|
191
304
|
entry.needsReauth = false;
|
|
305
|
+
entry.refreshFailureKind = null;
|
|
306
|
+
entry.refreshFailureCount = 0;
|
|
307
|
+
entry.lastRefreshError = null;
|
|
308
|
+
entry.refreshRetryAt = null;
|
|
192
309
|
state.grants.set(grantKey, entry);
|
|
193
310
|
if (config.persistSecrets) await grantStore.put(next);
|
|
194
311
|
for (const [key, record] of state.connections) {
|
|
195
|
-
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
|
+
});
|
|
196
321
|
}
|
|
197
322
|
scheduleRefresh(grantKey);
|
|
198
323
|
return next;
|
|
@@ -204,20 +329,61 @@ export async function apply(ctx, config) {
|
|
|
204
329
|
}
|
|
205
330
|
}
|
|
206
331
|
|
|
207
|
-
function scheduleRefresh(grantKey) {
|
|
332
|
+
function scheduleRefresh(grantKey, { delayMs } = {}) {
|
|
208
333
|
const entry = state.grants.get(grantKey);
|
|
209
334
|
if (!entry) return;
|
|
210
335
|
if (entry.timer) clearTimeout(entry.timer);
|
|
211
|
-
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);
|
|
212
344
|
entry.timer = setTimeout(() => {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
logger.warn(`grant ${grantKey} refresh failed: ${error.message}`);
|
|
216
|
-
});
|
|
345
|
+
entry.timer = undefined;
|
|
346
|
+
refreshGrantWithRecovery(grantKey, 'scheduled').catch(() => {});
|
|
217
347
|
}, delay);
|
|
218
348
|
entry.timer.unref?.();
|
|
219
349
|
}
|
|
220
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
|
+
|
|
221
387
|
async function retireGrantIfUnused(grantKey, { revoke = false } = {}) {
|
|
222
388
|
if ([...state.connections.values()].some((record) => record.auth?.grantKey === grantKey)) return false;
|
|
223
389
|
const entry = state.grants.get(grantKey);
|
|
@@ -375,14 +541,30 @@ export async function apply(ctx, config) {
|
|
|
375
541
|
|
|
376
542
|
function grantFailure(record) {
|
|
377
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
|
+
}
|
|
378
549
|
const grantKey = record.auth.grantKey;
|
|
379
550
|
const entry = grantKey ? state.grants.get(grantKey) : null;
|
|
380
551
|
if (!entry) return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权缺失,请重新授权' };
|
|
381
552
|
const clientSecretExpired = entry.grant.clientSecret && entry.grant.clientSecretExpiresAt > 0
|
|
382
553
|
&& entry.grant.clientSecretExpiresAt * 1000 <= Date.now();
|
|
383
|
-
if (entry.needsReauth || clientSecretExpired
|
|
554
|
+
if (entry.needsReauth || clientSecretExpired) {
|
|
384
555
|
return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权已过期或刷新失败,请重新授权' };
|
|
385
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
|
+
}
|
|
386
568
|
return null;
|
|
387
569
|
}
|
|
388
570
|
|
|
@@ -444,6 +626,7 @@ export async function apply(ctx, config) {
|
|
|
444
626
|
const pendingServers = results.filter((result) => result.ok && result.kind === 'managed').length;
|
|
445
627
|
const failedServers = results.filter((result) => !result.ok).length;
|
|
446
628
|
const authFailures = results.filter((result) => !result.ok && result.kind === 'auth').length;
|
|
629
|
+
const refreshFailures = results.filter((result) => !result.ok && result.kind === 'refresh').length;
|
|
447
630
|
let connectionState = 'configured';
|
|
448
631
|
if (records.length === 0) connectionState = 'disconnected';
|
|
449
632
|
else if (enabledRecords.length === 0) connectionState = 'disabled';
|
|
@@ -451,10 +634,11 @@ export async function apply(ctx, config) {
|
|
|
451
634
|
else if (availableServers > 0 && (failedServers > 0 || pendingServers > 0)) connectionState = 'degraded';
|
|
452
635
|
else if (pendingServers > 0 && failedServers === 0) connectionState = 'configured';
|
|
453
636
|
else if (authFailures > 0) connectionState = 'reauth';
|
|
637
|
+
else if (refreshFailures > 0) connectionState = 'recovering';
|
|
454
638
|
else if (failedServers > 0) connectionState = 'unavailable';
|
|
455
639
|
const labels = {
|
|
456
640
|
disconnected: '未连接', configured: '已配置', disabled: '已停用', healthy: '已连接',
|
|
457
|
-
degraded: '部分异常', reauth: '需重新授权', unavailable: '连接异常',
|
|
641
|
+
degraded: '部分异常', reauth: '需重新授权', recovering: '自动重试中', unavailable: '连接异常',
|
|
458
642
|
};
|
|
459
643
|
return {
|
|
460
644
|
connectorId,
|
|
@@ -519,6 +703,156 @@ export async function apply(ctx, config) {
|
|
|
519
703
|
return env;
|
|
520
704
|
}
|
|
521
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
|
+
|
|
522
856
|
/* ───────────────────────── api 门面 ───────────────────────── */
|
|
523
857
|
|
|
524
858
|
const api = {
|
|
@@ -578,18 +912,67 @@ export async function apply(ctx, config) {
|
|
|
578
912
|
if (!connector) return { ok: false, message: `连接器 "${connectorId}" 不存在(可先 mcp_connector_refresh_catalog 或 mcp_connector_catalog 查看)` };
|
|
579
913
|
|
|
580
914
|
if (connector.auth.mode === 'oauth2-pkce') {
|
|
581
|
-
const
|
|
582
|
-
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]);
|
|
583
928
|
const promise = (async () => {
|
|
584
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));
|
|
585
956
|
const previousGrantKeys = new Set(
|
|
586
957
|
[...state.grants]
|
|
587
|
-
.filter(([, entry]) => entry.grant.connectorIds?.
|
|
958
|
+
.filter(([, entry]) => entry.grant.connectorIds?.some((id) => sharingConnectorIds.has(id)))
|
|
588
959
|
.map(([key]) => key),
|
|
589
960
|
);
|
|
590
|
-
const
|
|
961
|
+
const authorizationConnector = oauthAuthorizationDescriptor(connector, sharingConnectors);
|
|
962
|
+
const authz = await oauthAuthorize({ connector: authorizationConnector, config, logger, signal });
|
|
591
963
|
const grantKey = grantStore.keyFor(config.account, authz.issuer, authz.clientId, authz.scope);
|
|
592
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))];
|
|
593
976
|
const grant = {
|
|
594
977
|
key: grantKey,
|
|
595
978
|
issuer: authz.issuer,
|
|
@@ -601,52 +984,27 @@ export async function apply(ctx, config) {
|
|
|
601
984
|
scope: authz.scope,
|
|
602
985
|
account: config.account,
|
|
603
986
|
accessToken: authz.token.accessToken,
|
|
604
|
-
accessTokenExpiresAt: Date.now() + authz.token.expiresIn * 1000
|
|
987
|
+
accessTokenExpiresAt: Date.now() + authz.token.expiresIn * 1000,
|
|
605
988
|
refreshToken: authz.token.refreshToken ?? '',
|
|
606
|
-
authorizedResources
|
|
607
|
-
|
|
608
|
-
.map((s) => s.url).length
|
|
609
|
-
? connector.servers.filter((s) => authz.grantedKeys.includes(s.serverKey)).map((s) => s.url)
|
|
610
|
-
: [entryResource],
|
|
611
|
-
connectorIds: [connector.id],
|
|
989
|
+
authorizedResources,
|
|
990
|
+
connectorIds: grantedConnectorIds,
|
|
612
991
|
updatedAt: Date.now(),
|
|
613
992
|
};
|
|
614
|
-
|
|
993
|
+
const runtime = grantRuntime(grant);
|
|
994
|
+
state.grants.set(grantKey, runtime);
|
|
615
995
|
if (config.persistSecrets) await grantStore.put(grant);
|
|
616
|
-
|
|
617
|
-
const records = [];
|
|
618
|
-
for (const sk of authz.grantedKeys) {
|
|
619
|
-
const server = connector.servers.find((s) => s.serverKey === sk);
|
|
620
|
-
if (!server) continue;
|
|
621
|
-
const record = {
|
|
622
|
-
key: `${connector.id}-${sk}`,
|
|
623
|
-
connectorId: connector.id,
|
|
624
|
-
kind: 'oauth',
|
|
625
|
-
name: connector.servers.length > 1 ? `${connector.name}·${sk}` : connector.name,
|
|
626
|
-
serverKey: sk,
|
|
627
|
-
transport: server.transport,
|
|
628
|
-
url: server.url,
|
|
629
|
-
serverName: server.serverName,
|
|
630
|
-
headers: server.headers,
|
|
631
|
-
auth: { mode: 'oauth', grantKey },
|
|
632
|
-
enabled: true,
|
|
633
|
-
createdAt: Date.now(),
|
|
634
|
-
updatedAt: Date.now(),
|
|
635
|
-
};
|
|
636
|
-
records.push(record);
|
|
637
|
-
}
|
|
638
|
-
const committed = await commitConnectionRecords(records);
|
|
996
|
+
const committed = await attachConnectorsToGrant(grantKey, runtime, targetConnectors, requestedConnectorIds);
|
|
639
997
|
const created = committed.map((record) => record.key);
|
|
640
|
-
cacheHealth(connector.id, committed, readinessResults(committed));
|
|
641
|
-
scheduleRefresh(grantKey);
|
|
642
998
|
let retiredGrantCount = 0;
|
|
643
999
|
for (const previousKey of previousGrantKeys) {
|
|
644
1000
|
if (previousKey !== grantKey && await retireGrantIfUnused(previousKey, { revoke: true })) retiredGrantCount += 1;
|
|
645
1001
|
}
|
|
646
1002
|
return {
|
|
647
1003
|
ok: true,
|
|
648
|
-
message:
|
|
649
|
-
|
|
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 },
|
|
650
1008
|
};
|
|
651
1009
|
} catch (error) {
|
|
652
1010
|
logger.error(`oauth connect failed: ${error.message}`);
|
|
@@ -656,11 +1014,11 @@ export async function apply(ctx, config) {
|
|
|
656
1014
|
: { ok: false, message: `连接失败: ${error.message}` };
|
|
657
1015
|
}
|
|
658
1016
|
})();
|
|
659
|
-
state.oauthConnects.set(
|
|
1017
|
+
state.oauthConnects.set(sharingKey, { promise, requestedConnectorIds });
|
|
660
1018
|
try {
|
|
661
1019
|
return await promise;
|
|
662
1020
|
} finally {
|
|
663
|
-
if (state.oauthConnects.get(
|
|
1021
|
+
if (state.oauthConnects.get(sharingKey)?.promise === promise) state.oauthConnects.delete(sharingKey);
|
|
664
1022
|
}
|
|
665
1023
|
}
|
|
666
1024
|
|
|
@@ -843,6 +1201,9 @@ export async function apply(ctx, config) {
|
|
|
843
1201
|
grantKey: record.auth.grantKey,
|
|
844
1202
|
accessTokenExpiresAt: g.grant.accessTokenExpiresAt,
|
|
845
1203
|
needsReauth: !!g.needsReauth,
|
|
1204
|
+
refreshFailureKind: g.refreshFailureKind,
|
|
1205
|
+
lastRefreshError: g.lastRefreshError,
|
|
1206
|
+
refreshRetryAt: g.refreshRetryAt,
|
|
846
1207
|
}
|
|
847
1208
|
: { grantKey: record.auth.grantKey, missing: true };
|
|
848
1209
|
}
|
|
@@ -924,7 +1285,7 @@ export async function apply(ctx, config) {
|
|
|
924
1285
|
summaries.push(cacheHealth(id, records, [...immediateResults, ...checkedResults]));
|
|
925
1286
|
}
|
|
926
1287
|
const healthy = summaries.filter((item) => item.connectionState === 'healthy').length;
|
|
927
|
-
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;
|
|
928
1289
|
return {
|
|
929
1290
|
ok: true,
|
|
930
1291
|
message: targetIds.length === 0
|
|
@@ -957,24 +1318,34 @@ export async function apply(ctx, config) {
|
|
|
957
1318
|
if (plan.summary.alreadyMigrated) { skipped.push(`${plan.summary.connectorName}(已迁移)`); continue; }
|
|
958
1319
|
const grantKey = grantStore.keyFor(config.account, plan.candidate.grant.issuer, plan.candidate.grant.clientId, plan.candidate.grant.scope);
|
|
959
1320
|
const grant = toConnectorGrant(plan.candidate, { key: grantKey, account: config.account, connectorIds: [plan.connector.id] });
|
|
960
|
-
state.grants.set(grantKey,
|
|
1321
|
+
state.grants.set(grantKey, grantRuntime(grant));
|
|
961
1322
|
if (config.persistSecrets) await grantStore.put(grant);
|
|
962
1323
|
|
|
963
1324
|
let enabled = true;
|
|
1325
|
+
let provisionNow = true;
|
|
964
1326
|
let lastError;
|
|
1327
|
+
const legacyConflict = legacyConflictForConnector(plan.connector);
|
|
1328
|
+
if (legacyConflict) {
|
|
1329
|
+
warnLegacyConflict(legacyConflict);
|
|
1330
|
+
provisionNow = false;
|
|
1331
|
+
lastError = `${legacyConflict.message};授权已复制,重启后将由 MCP连接器恢复`;
|
|
1332
|
+
}
|
|
965
1333
|
if (grant.accessTokenExpiresAt <= Date.now()) {
|
|
966
|
-
try { await
|
|
1334
|
+
try { await refreshGrantWithRecovery(grantKey, 'legacy-migration'); }
|
|
967
1335
|
catch (error) {
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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}`;
|
|
971
1342
|
}
|
|
972
1343
|
} else {
|
|
973
1344
|
scheduleRefresh(grantKey);
|
|
974
1345
|
}
|
|
975
1346
|
const records = toConnectionRecords(plan, grantKey, { enabled, lastError });
|
|
976
1347
|
for (const record of records) {
|
|
977
|
-
if (enabled) await upsertRecord(record);
|
|
1348
|
+
if (enabled && provisionNow) await upsertRecord(record);
|
|
978
1349
|
else await persistRecord(record);
|
|
979
1350
|
}
|
|
980
1351
|
migrated.push({ connectorId: plan.connector.id, keys: records.map((record) => record.key), enabled });
|
|
@@ -1207,6 +1578,7 @@ export async function apply(ctx, config) {
|
|
|
1207
1578
|
/* ───────────────────────── 启动恢复 ───────────────────────── */
|
|
1208
1579
|
|
|
1209
1580
|
await loadCatalog();
|
|
1581
|
+
detectLegacyPluginConflicts();
|
|
1210
1582
|
|
|
1211
1583
|
for (const [key, raw] of await connectionStore.entries().catch(() => [])) {
|
|
1212
1584
|
try {
|
|
@@ -1218,7 +1590,7 @@ export async function apply(ctx, config) {
|
|
|
1218
1590
|
}
|
|
1219
1591
|
for (const [key, raw] of await grantStore.entries().catch(() => [])) {
|
|
1220
1592
|
try {
|
|
1221
|
-
state.grants.set(key,
|
|
1593
|
+
state.grants.set(key, grantRuntime(raw));
|
|
1222
1594
|
} catch (error) {
|
|
1223
1595
|
logger.warn(`skip invalid stored grant "${key}": ${error.message}`);
|
|
1224
1596
|
}
|
|
@@ -1230,16 +1602,19 @@ export async function apply(ctx, config) {
|
|
|
1230
1602
|
for (const [grantKey, g] of state.grants) {
|
|
1231
1603
|
if (g.grant.accessTokenExpiresAt <= Date.now()) {
|
|
1232
1604
|
try {
|
|
1233
|
-
await
|
|
1234
|
-
} catch {
|
|
1235
|
-
g.needsReauth = true;
|
|
1236
|
-
}
|
|
1605
|
+
await refreshGrantWithRecovery(grantKey, 'startup');
|
|
1606
|
+
} catch {}
|
|
1237
1607
|
} else {
|
|
1238
1608
|
scheduleRefresh(grantKey);
|
|
1239
1609
|
}
|
|
1240
1610
|
}
|
|
1241
1611
|
for (const [key, record] of state.connections) {
|
|
1242
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
|
+
}
|
|
1243
1618
|
await provision(ctx, config, record, grantMap()).catch((error) => {
|
|
1244
1619
|
logger.warn(`provision "${key}" failed: ${error.message}`);
|
|
1245
1620
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp-connector",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.23",
|
|
4
4
|
"description": "General-purpose MCP connector, connection manager, plugin extension, and integration marketplace for DeepSeek Harness, initiated and maintained by Qichacha/QCC.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
|
35
35
|
"test": "node --test",
|
|
36
|
-
"lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/oauth.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/sync-registry-stats.mjs",
|
|
36
|
+
"lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/oauth.js && node --check lib/grant-lifecycle.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/sync-registry-stats.mjs",
|
|
37
37
|
"registry:build": "node scripts/build-registry.mjs",
|
|
38
38
|
"registry:validate": "node scripts/probe-connector.mjs catalog/catalog.json --validate-only",
|
|
39
39
|
"dev:ui": "node scripts/ui-harness.mjs",
|
package/ui/index.html
CHANGED
|
@@ -471,7 +471,7 @@
|
|
|
471
471
|
const searchInput = $('#search');
|
|
472
472
|
const LABELS = {
|
|
473
473
|
details: '详情', connect: '连接', connected: '已连接', configured: '已配置', configure: '配置',
|
|
474
|
-
reauthorize: '需重新授权', connectionError: '连接异常', partialError: '部分异常', disabled: '已停用',
|
|
474
|
+
reauthorize: '需重新授权', recovering: '自动重试中', connectionError: '连接异常', partialError: '部分异常', disabled: '已停用',
|
|
475
475
|
send: '发送', fillSend: '填写并发送', connectToUse: '连接后使用', try: '💬 去试试',
|
|
476
476
|
connectTry: '🔗 连接后试用', configureConnect: '🔑 录入凭据并连接',
|
|
477
477
|
reconfigureCredential: '🔑 重新配置凭据', loading: '加载中…',
|
|
@@ -857,6 +857,7 @@
|
|
|
857
857
|
if (state === 'configured') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('configured')}</button>`;
|
|
858
858
|
if (state === 'disabled') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('disabled')}</button>`;
|
|
859
859
|
if (state === 'reauth') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn status-warning" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('reauthorize')}</button>`;
|
|
860
|
+
if (state === 'recovering') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn status-warning" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('recovering')}</button>`;
|
|
860
861
|
if (state === 'degraded') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn status-warning" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('partialError')}</button>`;
|
|
861
862
|
if (state === 'unavailable') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn status-bad" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('connectionError')}</button>`;
|
|
862
863
|
}
|
|
@@ -886,8 +887,8 @@
|
|
|
886
887
|
function rowHtml(r) {
|
|
887
888
|
const key = esc(r.key);
|
|
888
889
|
const authMode = r.authMode === 'oauth' ? '<span class="badge auth-oauth">OAuth</span>' : `<span class="badge">${esc(r.authMode || 'manual')}</span>`;
|
|
889
|
-
const healthLabels = { healthy: '已连接', configured: '已配置', reauth: '需重新授权', degraded: '部分异常', unavailable: '连接异常', disabled: '已停用' };
|
|
890
|
-
const healthClass = r.connectionState === 'healthy' ? 'pass' : ['reauth', 'degraded'].includes(r.connectionState) ? 'partial' : r.connectionState === 'unavailable' ? 'fail' : '';
|
|
890
|
+
const healthLabels = { healthy: '已连接', configured: '已配置', reauth: '需重新授权', recovering: '自动重试中', degraded: '部分异常', unavailable: '连接异常', disabled: '已停用' };
|
|
891
|
+
const healthClass = r.connectionState === 'healthy' ? 'pass' : ['reauth', 'recovering', 'degraded'].includes(r.connectionState) ? 'partial' : r.connectionState === 'unavailable' ? 'fail' : '';
|
|
891
892
|
const healthBadge = `<span class="badge ${healthClass}">${esc(healthLabels[r.connectionState] || '已配置')}</span>`;
|
|
892
893
|
const enabled = r.enabled !== false;
|
|
893
894
|
return `<div class="row" data-key="${key}">
|
|
@@ -1208,7 +1209,7 @@
|
|
|
1208
1209
|
if (!d) return;
|
|
1209
1210
|
detailConnector = d;
|
|
1210
1211
|
detailFailureState = d.connectionState || '';
|
|
1211
|
-
detailNeedsReconfigure = ['reauth', 'unavailable'].includes(detailFailureState);
|
|
1212
|
+
detailNeedsReconfigure = ['reauth', 'recovering', 'unavailable'].includes(detailFailureState);
|
|
1212
1213
|
const loadId = ++detailLoadId;
|
|
1213
1214
|
$('#detail-icon').innerHTML = isImageIcon(d.icon)
|
|
1214
1215
|
? `<img src="${esc(d.icon)}" alt="" onerror="this.parentElement.innerHTML='🔌';" />`
|