dsh-mcp-connector 0.2.41 → 0.2.43

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 CHANGED
@@ -4,6 +4,25 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.43] - 2026-09-12
8
+
9
+ ### Added
10
+
11
+ - 新增同源实时状态 SSE:连接、目录、健康、工具目录、治理和作用域发生变化后,市场页面自动刷新,无需轮询。
12
+ - 新增“最后一次成功工具缓存”:实时 `tools/list` 或 Host 工具观测失败时仍可查看上次成功发现的工具,并明确标注缓存时间与超过 24 小时的陈旧状态。
13
+ - 新增 `mcp_connector_tool_search` 与 `mcp_connector_tool_detail` 渐进式发现工具;支持按名称、标题和描述搜索,并按需读取安全裁剪后的输入参数 schema,不执行目标 MCP 工具。
14
+
15
+ ### Security
16
+
17
+ - SSE 事件仅包含通用类别、递增序号与时间;不会发送连接标识、端点、错误、参数、结果或凭据。
18
+ - 工具缓存按连接配置签名隔离并设置工具数、描述、schema 与总容量上限;剔除 schema 默认值和示例,断开、改配或快照恢复后清理旧缓存。
19
+
20
+ ## [0.2.42] - 2026-09-11
21
+
22
+ ### Fixed
23
+
24
+ - 示例 Prompt 的“发送”动作会在 DSH 0.1.2 优先调用 `uiWorkspace.connectWorkspace`,并兼容 DSH 0.1.1 的 `workspaces.connectWorkspace` 与 `sessions.create` 安全降级;不再因工作区导航 API 迁移报错。
25
+
7
26
  ## [0.2.41] - 2026-09-09
8
27
 
9
28
  ### Added
@@ -638,7 +657,8 @@
638
657
  - 外部 URL 与导入 Header 执行安全校验。
639
658
  - iframe 消息校验同源和消息来源。
640
659
 
641
- [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.41...HEAD
660
+ [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.42...HEAD
661
+ [0.2.42]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.41...v0.2.42
642
662
  [0.2.41]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.40...v0.2.41
643
663
  [0.2.40]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.39...v0.2.40
644
664
  [0.2.39]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.38...v0.2.39
package/README.en.md CHANGED
@@ -54,7 +54,9 @@ If the plugin helps you connect an MCP server faster, consider [starring the rep
54
54
  - OAuth 2.0 Authorization Code with PKCE, including DCR public clients and `client_secret_post` / `client_secret_basic`; API key/Bearer/unauthenticated HTTP configuration, stdio local-process configuration, and `mcpServers` JSON import.
55
55
  - Installation from a credential-free connector descriptor URL.
56
56
  - 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.
57
- - Dynamic tool discovery grouped by MCP server, including descriptions, search, batched rendering, and an independent scroll region.
57
+ - Dynamic tool discovery grouped by MCP server, including descriptions, search, parameter details, batched rendering, and an independent scroll region. When live discovery fails, the UI shows the last successful tool cache with observation-time and stale markers.
58
+ - Same-origin SSE updates the UI after connection, catalog, health, tool, governance, or scope changes. Event payloads contain no connection identifiers, endpoints, errors, or credentials.
59
+ - Progressive `mcp_connector_tool_search` and `mcp_connector_tool_detail` tools search last-success metadata and reveal a bounded, sanitized input schema on demand without executing the target MCP tool.
58
60
  - Curated prompt templates that can open a DSH conversation and prefill its draft; missing variables are requested before the prompt is sent.
59
61
  - 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; a cross-process lock and per-grant atomic journal prevent Desktop and Web hosts from consuming or overwriting the same rotating refresh token.
60
62
  - In-place configuration editing for custom and JSON-imported connections: edit normalized JSON from Installed and reconnect without deleting the connection. Existing sensitive values use local keep markers, and validation, startup, or persistence failures leave the previous connection usable.
@@ -68,7 +70,7 @@ If the plugin helps you connect an MCP server faster, consider [starring the rep
68
70
  - Explicit, non-destructive migration from the two earlier Qichacha OAuth plugins, plus active-plugin conflict detection that blocks duplicate server management and credential overwrites.
69
71
 
70
72
  <!-- catalog-stats:start -->
71
- As of 2026-09-05, the public Registry publishes 105 connector descriptors. After merging and deduplicating them with the 4 bundled Qichacha cards, the Marketplace exposes 109 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.
73
+ As of 2026-09-11, the public Registry publishes 106 connector descriptors. After merging and deduplicating them with the 4 bundled Qichacha cards, the Marketplace exposes 110 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.
72
74
  <!-- catalog-stats:end -->
73
75
 
74
76
  ## Interface and demo
@@ -176,7 +178,7 @@ npm run dev:ui
176
178
 
177
179
  Every Registry merge regenerates `catalog-stats.json`; an hourly workflow in this repository synchronizes the Chinese and English product copy plus a local stats snapshot. The static npm README updates with package releases, while the live badges above read the Registry directly and therefore stay current without another npm release.
178
180
 
179
- The current public version is [`dsh-mcp-connector@0.2.41`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.41](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.41).
181
+ The current public version is [`dsh-mcp-connector@0.2.43`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.43](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.43).
180
182
 
181
183
  See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
182
184
 
package/README.md CHANGED
@@ -52,7 +52,8 @@ dsh plugin --profile web add dsh-mcp-connector
52
52
  - 侧边栏入口可按当前 profile 隐藏;隐藏后仍可从“设置 → 插件 → 插件配置 → MCP连接器”临时打开现有连接器弹框,用完即关。
53
53
  - 图形化市场:默认“全部”按推荐与 9 类业务分类分章节展示,每章先展示 4 张并可展开;分类栏固定可见,单分类页展示全部卡片。
54
54
  - 图形化添加:手动 HTTP/stdio、`mcpServers` JSON、连接器描述 URL 三种入口,失败时保留表单并给出修复建议。
55
- - 连接器详情:精选 Prompt 优先展示,点击可带入 DSH 新会话;工具按 Server 分组,支持描述、搜索和独立滚动。
55
+ - 连接器详情:精选 Prompt 优先展示,点击可带入 DSH 新会话;工具按 Server 分组,支持描述、搜索、参数详情和独立滚动。实时发现失败时显示带时间/陈旧标记的最后成功工具缓存。
56
+ - 实时状态:页面通过同源 SSE 接收连接、目录、健康、工具、治理和作用域变化通知并自动刷新;事件不携带连接标识、端点、错误或凭据。
56
57
  - Prompt 模板:使用 `{{company}}` 等变量,发送前填写真实查询主体。
57
58
  - 三种接入:OAuth 2.0 PKCE、自定义 HTTP/stdio、导入 `mcpServers` JSON;也支持从连接器描述 URL 安装。OAuth 动态注册兼容公共客户端以及 `client_secret_post` / `client_secret_basic` 机密客户端。
58
59
  - 市场 Bearer/API Key 连接器先执行 MCP initialize 连通性与凭据校验,全部 HTTP Server 通过后才持久化凭据并进入“已安装”;stdio 卡片可声明多个本机凭据字段及其环境变量映射。
@@ -67,10 +68,10 @@ dsh plugin --profile web add dsh-mcp-connector
67
68
  - 插件版本与一键更新:版本发现独立于安装来源;页面通过 Update Provider 适配层探测安全更新能力。DSH Market API v1 是首个适配器,支持进度、稳定失败码、回滚及按宿主能力提供的重启/刷新操作;无可用 Provider 时回退到当前插件市场或 npm。
68
69
  - Registry 工具链:Schema/唯一性/密钥审计、MCP/OAuth 无凭据探针、每周健康巡检。
69
70
  - 平滑迁移:显式扫描并复制两个旧企查查 OAuth 插件授权;检测到旧插件仍启用并管理同名 Server 时阻断重复连接,避免凭据相互覆盖。
70
- - 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`export_config`、`snapshot`、`install_from_url`、`status`、`scope`、`health_check`、`policy`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
71
+ - 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`export_config`、`snapshot`、`install_from_url`、`status`、`scope`、`health_check`、`policy`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`、`tool_search`、`tool_detail`。搜索/详情只做渐进式能力发现,不执行目标 MCP 工具。
71
72
 
72
73
  <!-- catalog-stats:start -->
73
- 截至 2026-09-05,公共 Registry 已发布 105 条连接器描述;与随包的 4 张企查查卡片合并去重后,市场页可浏览 109 张卡片,覆盖企业数据、金融投资、法律合规、开发工具、办公协作、调研分析、设计创意、效率工具、其他 9 类。推荐位严格保留 4 张企查查卡片、北大法宝和 Wind,共 6 张;其他连接器按业务分类展示。Registry 可独立持续更新,实际数量以客户端刷新后的市场页签徽标和上方实时统计徽标为准。
74
+ 截至 2026-09-11,公共 Registry 已发布 106 条连接器描述;与随包的 4 张企查查卡片合并去重后,市场页可浏览 110 张卡片,覆盖企业数据、金融投资、法律合规、开发工具、办公协作、调研分析、设计创意、效率工具、其他 9 类。推荐位严格保留 4 张企查查卡片、北大法宝和 Wind,共 6 张;其他连接器按业务分类展示。Registry 可独立持续更新,实际数量以客户端刷新后的市场页签徽标和上方实时统计徽标为准。
74
75
  <!-- catalog-stats:end -->
75
76
 
76
77
  ## 界面与演示
@@ -172,7 +173,7 @@ npm run dev:ui
172
173
 
173
174
  公共 Registry 每次合并后会生成 `catalog-stats.json`;本仓库的定时工作流每小时同步中英文介绍和统计快照。npm 页面中的静态正文随版本发布更新,上方动态统计徽标则直接读取 Registry,可在不发布新 npm 版本时保持实时数量一致。
174
175
 
175
- 当前公开版本为 [`dsh-mcp-connector@0.2.41`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.41](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.41)。
176
+ 当前公开版本为 [`dsh-mcp-connector@0.2.43`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.43](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.43)。
176
177
 
177
178
  版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
178
179
  Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
@@ -99,10 +99,12 @@ OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据
99
99
 
100
100
  1. 阅读服务说明、鉴权方式、数据范围与可能产生的费用或副作用。
101
101
  2. 在“试试这样用”中选择示例 Prompt;带变量的模板会先要求补齐查询主体等信息。
102
- 3. 展开“工具详情”查看 Server 数量、工具名称与描述,并可搜索工具;连接后还可按 Connection、Server、Tool 设置治理规则。
102
+ 3. 展开“工具详情”查看 Server 数量、工具名称与描述,并可搜索工具;点击“参数详情”可按需查看输入 schema;连接后还可按 Connection、Server、Tool 设置治理规则。
103
103
  4. 点击“去试试”或 Prompt 的发送按钮,在当前工作区创建或复用空白会话并写入草稿。
104
104
 
105
- 连接成功后,工具按 `mcp__<serverName>__*` 前缀提供给模型。工具清单来自服务端,实际数量会随服务商权限和版本变化。详情与“已安装”列表会展示最近一次诊断的阶段、稳定错误码、说明、建议动作和最近成功时间。
105
+ 连接成功后,工具按 `mcp__<serverName>__*` 前缀提供给模型。工具清单来自服务端,实际数量会随服务商权限和版本变化。成功发现后,插件会在当前 profile 保存一份安全裁剪的工具元数据;实时发现失败时仍显示这份“最后成功缓存”,但保持连接失败/未知状态并标注缓存时间,超过 24 小时还会显示陈旧提示。详情与“已安装”列表会展示最近一次诊断的阶段、稳定错误码、说明、建议动作和最近成功时间。
106
+
107
+ 对话中可用 `mcp_connector_tool_search` 按名称、标题或描述搜索缓存,再用 `mcp_connector_tool_detail` 精确读取某个工具的参数 schema。两者只用于发现,不代理或执行 MCP 调用。
106
108
 
107
109
  ## 6. 添加自定义连接
108
110
 
@@ -233,6 +235,8 @@ OAuth 断开时,插件会尽力调用服务商的撤销端点;无撤销端
233
235
 
234
236
  - 健康摘要和最近成功时间目前只保留在插件进程内;重启后先显示“状态未知”,直至重新检查。
235
237
  - HTTP 工具清单使用只读 MCP `tools/list` 发现;stdio 工具清单读取 Host 已注册工具。Host 不提供可观测状态时只能报告未知。
238
+ - 最后成功工具缓存只保存名称、标题、描述和安全裁剪后的输入 schema,不保存调用参数、调用结果、原始错误或凭据;断开、修改连接配置或恢复快照会清理相关缓存。
239
+ - 页面实时状态使用同源 SSE,事件只发送变化类别、序号和时间;断线时浏览器按 EventSource 规则自动重连,页面重新可见时主动刷新一次。
236
240
  - 连接凭据、快照、治理规则和作用域历史都存储在当前 profile;project/global 不跨 profile 同步。
237
241
  - Workspace 被删除后,原 project-only 绑定保持 fail closed,需手动移动到当前项目或全局。
238
242
  - 连接级停用可逆;断开会删除本机连接,并在授权不再共享时尽力撤销 OAuth。非 OAuth 配置可用断开前快照恢复;已撤销的 OAuth 必须重新授权。
package/lib/client.js CHANGED
@@ -999,7 +999,23 @@ window.__ModuleLoader__.load({
999
999
  if (targetWorkspaceId === void 0) {
1000
1000
  throw new Error("请先选择一个工作空间,再使用示例 Prompt");
1001
1001
  }
1002
- const sessionId = await ctx.workspaces.connectWorkspace(targetWorkspaceId);
1002
+ // DSH 0.1.2 将跨 Controller 的导航能力从 workspaces 移到
1003
+ // uiWorkspace;0.1.1 Desktop 仍暴露旧方法。优先使用当前公开能力,
1004
+ // 再兼容旧版,最后以 sessions.create 保证未来移除桥接方法时仍能
1005
+ // 在目标 Workspace 创建原生会话。
1006
+ const uiWorkspace = typeof ctx.get === "function"
1007
+ ? ctx.get("uiWorkspace")
1008
+ : ctx.uiWorkspace;
1009
+ let sessionId;
1010
+ if (typeof uiWorkspace?.connectWorkspace === "function") {
1011
+ sessionId = await uiWorkspace.connectWorkspace(targetWorkspaceId);
1012
+ } else if (typeof ctx.workspaces?.connectWorkspace === "function") {
1013
+ sessionId = await ctx.workspaces.connectWorkspace(targetWorkspaceId);
1014
+ } else if (typeof ctx.sessions?.create === "function") {
1015
+ sessionId = await ctx.sessions.create({ workspaceId: targetWorkspaceId });
1016
+ } else {
1017
+ throw new Error("当前 DSH 版本没有可用的工作区会话创建能力,请升级 DSH 后重试");
1018
+ }
1003
1019
  const conversation = ctx.get("conversation");
1004
1020
  if (conversation === void 0) {
1005
1021
  throw new Error("DSH 对话服务尚未就绪,请稍后重试");
package/lib/index.js CHANGED
@@ -5,10 +5,11 @@
5
5
  * - 连接器目录(内置 + 远程 registry + config 注入 + 本地上下架覆盖)
6
6
  * - 三通道连接:通用 OAuth(PKCE) / 自定义配置 / 粘贴 JSON / URL 安装
7
7
  * - 连接记录与 OAuth grant 持久化,重启恢复与跨 Host Token 轮换
8
+ * - 实时状态 SSE、最后成功工具缓存与渐进式工具搜索/详情
8
9
  * - 通过 ctx.loader 动态配置 @deepseek-ai/dsh-mcp-client 条目
9
10
  * - 对话工具:mcp_connector_catalog / connect / configure / import_json /
10
11
  * export_config / snapshots / install_from_url / status / rename / set_enabled /
11
- * disconnect / refresh_catalog / publish
12
+ * disconnect / refresh_catalog / publish / tools_list / tool_search / tool_detail
12
13
  */
13
14
  import { randomUUID } from 'node:crypto';
14
15
  import z from '@deepseek-ai/schemastery';
@@ -23,7 +24,7 @@ import {
23
24
  DEFAULT_REFRESH_RETRY_MAX_MS,
24
25
  DEFAULT_CATALOG_TTL_MS,
25
26
  } from './constants.js';
26
- import { defineConnectorDomain, ConnectionStore, GrantStore, CatalogStore, SnapshotStore, GovernanceStore, ConnectionScopeStore } from './stores.js';
27
+ import { defineConnectorDomain, ConnectionStore, GrantStore, CatalogStore, SnapshotStore, GovernanceStore, ConnectionScopeStore, ToolCatalogStore } from './stores.js';
27
28
  import { normalizeConnectorDescriptor, normalizeConnectionRecord } from './schema.js';
28
29
  import { loadBundledCatalog, fetchRemoteCatalogWithFallback, mergeCatalog, listCatalog, auditDescriptor, auditRawDescriptor, readLimitedJson } from './catalog.js';
29
30
  import { describeOAuthAuthorizationError, oauthAuthorize } from './connectors/oauth-connector.js';
@@ -68,6 +69,14 @@ import {
68
69
  scopeTargets,
69
70
  } from './connection-scopes.js';
70
71
  import { installConnectorSettings } from './settings.js';
72
+ import {
73
+ connectionToolCacheSignature,
74
+ createToolCatalogRecord,
75
+ findToolDetails,
76
+ searchToolCatalog,
77
+ toolCatalogCacheView,
78
+ } from './tool-catalog-cache.js';
79
+ import { observeStatusEvents, StatusEventHub } from './status-events.js';
71
80
 
72
81
  export const name = 'mcp-connector';
73
82
 
@@ -137,6 +146,7 @@ export async function apply(ctx, config) {
137
146
  });
138
147
  const catalogStore = new CatalogStore(domain);
139
148
  const snapshotStore = new SnapshotStore(domain);
149
+ const toolCatalogStore = new ToolCatalogStore(domain);
140
150
  const governanceStore = new GovernanceStore(domain);
141
151
  const governancePolicies = new GovernancePolicyService(governanceStore);
142
152
  await governancePolicies.load();
@@ -155,7 +165,9 @@ export async function apply(ctx, config) {
155
165
  oauthConnects: new Map(), // sharingKey -> { promise, requestedConnectorIds }(防同 issuer 重复弹窗)
156
166
  warnedLegacyConflicts: new Set(),
157
167
  toolInventories: new Map(), // serverName -> { observed, observedAt, tools: Set<rawName> }
168
+ toolCatalogCache: new Map(), // connection key -> 最近成功、安全裁剪后的工具元数据
158
169
  };
170
+ const statusEvents = new StatusEventHub();
159
171
 
160
172
  const hostGovernance = createHostGovernanceController(ctx, {
161
173
  getRules: () => governancePolicies.snapshot().rules,
@@ -421,6 +433,7 @@ export async function apply(ctx, config) {
421
433
  });
422
434
  }
423
435
  scheduleRefresh(grantKey);
436
+ statusEvents.publish('status');
424
437
  return next;
425
438
  })();
426
439
  try {
@@ -472,6 +485,7 @@ export async function apply(ctx, config) {
472
485
  entry.refreshRetryAt = Date.now() + delay;
473
486
  scheduleRefresh(grantKey, { delayMs: delay });
474
487
  }
488
+ statusEvents.publish('status');
475
489
  return failure;
476
490
  }
477
491
 
@@ -494,6 +508,7 @@ export async function apply(ctx, config) {
494
508
  logger.info(`OAuth grant ${grantKey} adopted a newer cross-process journal token`);
495
509
  if (persisted.accessTokenExpiresAt > Date.now()) {
496
510
  scheduleRefresh(grantKey);
511
+ statusEvents.publish('status');
497
512
  return persisted;
498
513
  }
499
514
  }
@@ -523,6 +538,7 @@ export async function apply(ctx, config) {
523
538
  try {
524
539
  if (persisted.accessTokenExpiresAt > Date.now()) {
525
540
  scheduleRefresh(grantKey);
541
+ statusEvents.publish('status');
526
542
  return persisted;
527
543
  }
528
544
  return await grantStore.withRefreshLock(grantKey, () => refreshGrant(grantKey), {
@@ -609,6 +625,7 @@ export async function apply(ctx, config) {
609
625
  throw wrapped;
610
626
  }
611
627
  state.connections.delete(record.key);
628
+ await deleteLastSuccessfulTools(record.key);
612
629
  clearRecordHealth(record);
613
630
  }
614
631
 
@@ -957,6 +974,7 @@ export async function apply(ctx, config) {
957
974
  publicName: schema.name,
958
975
  title: schema.title || schema.name.slice(prefix.length),
959
976
  description: schema.description || '',
977
+ inputSchema: schema.parameters ?? {},
960
978
  }));
961
979
  return { supported: true, tools };
962
980
  } catch (error) {
@@ -965,6 +983,48 @@ export async function apply(ctx, config) {
965
983
  }
966
984
  }
967
985
 
986
+ async function cacheLastSuccessfulTools(record, tools) {
987
+ const entry = createToolCatalogRecord(record, tools);
988
+ state.toolCatalogCache.set(record.key, entry);
989
+ if (config.persistSecrets) {
990
+ try {
991
+ state.toolCatalogCache.set(record.key, await toolCatalogStore.put(entry));
992
+ } catch (error) {
993
+ logger.warn(`persist tool catalog "${record.key}" failed: ${error.message}`);
994
+ }
995
+ }
996
+ return state.toolCatalogCache.get(record.key);
997
+ }
998
+
999
+ async function deleteLastSuccessfulTools(key) {
1000
+ state.toolCatalogCache.delete(key);
1001
+ if (config.persistSecrets) {
1002
+ await toolCatalogStore.delete(key).catch((error) => {
1003
+ logger.warn(`delete tool catalog "${key}" failed: ${error.message}`);
1004
+ });
1005
+ }
1006
+ }
1007
+
1008
+ function cachedToolsForRecord(record) {
1009
+ const entry = state.toolCatalogCache.get(record.key);
1010
+ if (!entry
1011
+ || entry.connectorId !== record.connectorId
1012
+ || entry.serverName !== record.serverName
1013
+ || entry.connectionSignature !== connectionToolCacheSignature(record)) return null;
1014
+ return { entry, ...toolCatalogCacheView(entry) };
1015
+ }
1016
+
1017
+ function visibleToolCatalogEntries(workspaceId) {
1018
+ const entries = [];
1019
+ for (const record of state.connections.values()) {
1020
+ if (record.enabled === false) continue;
1021
+ if (workspaceId && !connectionVisibleInWorkspace(connectionScopes.binding(record.key), workspaceId)) continue;
1022
+ const cached = cachedToolsForRecord(record);
1023
+ if (cached) entries.push(cached.entry);
1024
+ }
1025
+ return entries;
1026
+ }
1027
+
968
1028
  function readinessResults(records, validationResults = []) {
969
1029
  return records.map((record, index) => {
970
1030
  const validated = validationResults[index];
@@ -2168,6 +2228,7 @@ export async function apply(ctx, config) {
2168
2228
  .map((key) => state.connections.get(key)?.auth?.grantKey)
2169
2229
  .filter(Boolean));
2170
2230
  const restored = await replaceConnectionSet(snapshot.targetKeys, snapshot.records);
2231
+ for (const key of snapshot.targetKeys) await deleteLastSuccessfulTools(key);
2171
2232
  let retiredGrantCount = 0;
2172
2233
  for (const grantKey of previousGrantKeys) {
2173
2234
  if (await retireGrantIfUnused(grantKey, { revoke: true })) retiredGrantCount += 1;
@@ -2444,6 +2505,7 @@ export async function apply(ctx, config) {
2444
2505
  };
2445
2506
  }
2446
2507
  const { committed: [committed], snapshot } = await commitWithSnapshot([next], 'reconfigure');
2508
+ await deleteLastSuccessfulTools(committed.key);
2447
2509
  if (committed.connectorId && committed.enabled !== false) {
2448
2510
  cacheHealth(committed.connectorId, [committed], readinessResults([committed], validation.detail.results));
2449
2511
  }
@@ -2579,6 +2641,70 @@ export async function apply(ctx, config) {
2579
2641
  return { ok: true, message: `已将 "${connector.name}" ${published ? '上架' : '下架'}` };
2580
2642
  },
2581
2643
 
2644
+ async toolSearch(input = {}) {
2645
+ try {
2646
+ const connectorId = String(input.connectorId ?? '').trim() || undefined;
2647
+ if (connectorId && !state.merged.some((connector) => connector.id === connectorId)) {
2648
+ return { ok: false, message: `连接器 "${connectorId}" 不存在` };
2649
+ }
2650
+ const items = searchToolCatalog(visibleToolCatalogEntries(input.workspaceId), {
2651
+ query: input.query,
2652
+ connectorId,
2653
+ limit: input.limit,
2654
+ });
2655
+ const staleCount = items.filter((item) => item.stale).length;
2656
+ return {
2657
+ ok: true,
2658
+ message: `最后成功工具缓存中找到 ${items.length} 个匹配项${staleCount ? `,其中 ${staleCount} 个已超过 24 小时` : ''}`,
2659
+ detail: {
2660
+ items,
2661
+ source: 'last-success-cache',
2662
+ discoveryOnly: true,
2663
+ credentialsStored: false,
2664
+ },
2665
+ };
2666
+ } catch (error) {
2667
+ return { ok: false, message: `搜索工具失败: ${error.message}` };
2668
+ }
2669
+ },
2670
+
2671
+ async toolDetail(input = {}) {
2672
+ try {
2673
+ const connectorId = String(input.connectorId ?? '').trim() || undefined;
2674
+ const serverName = String(input.serverName ?? '').trim() || undefined;
2675
+ const matches = findToolDetails(visibleToolCatalogEntries(input.workspaceId), {
2676
+ toolName: input.toolName,
2677
+ connectorId,
2678
+ serverName,
2679
+ });
2680
+ if (matches.length === 0) {
2681
+ return { ok: false, message: `最后成功工具缓存中找不到 "${String(input.toolName ?? '').trim()}"` };
2682
+ }
2683
+ if (matches.length > 1) {
2684
+ return {
2685
+ ok: false,
2686
+ message: `找到 ${matches.length} 个同名工具,请补充 connectorId 或 serverName`,
2687
+ detail: {
2688
+ candidates: matches.map(({ connectorId: id, serverName: server, name }) => ({ connectorId: id, serverName: server, name })),
2689
+ discoveryOnly: true,
2690
+ },
2691
+ };
2692
+ }
2693
+ return {
2694
+ ok: true,
2695
+ message: `工具详情:${matches[0].serverName}/${matches[0].name}${matches[0].stale ? '(缓存已超过 24 小时)' : ''}`,
2696
+ detail: {
2697
+ tool: matches[0],
2698
+ source: 'last-success-cache',
2699
+ discoveryOnly: true,
2700
+ credentialsStored: false,
2701
+ },
2702
+ };
2703
+ } catch (error) {
2704
+ return { ok: false, message: `读取工具详情失败: ${error.message}` };
2705
+ }
2706
+ },
2707
+
2582
2708
  /**
2583
2709
  * 从已连接的 MCP server 动态获取工具清单
2584
2710
  * 通过 MCP JSON-RPC 协议调用 tools/list 方法
@@ -2617,13 +2743,22 @@ export async function apply(ctx, config) {
2617
2743
  const snapshot = (connector.toolsSnapshot ?? []).find((item) =>
2618
2744
  item.serverKey === record.serverKey || item.serverName === record.serverName);
2619
2745
  const liveTools = registered.tools;
2746
+ let cached = cachedToolsForRecord(record);
2620
2747
  if (liveTools.length > 0) {
2621
2748
  state.toolInventories.set(record.serverName, {
2622
2749
  observed: true,
2623
2750
  observedAt: Date.now(),
2624
2751
  tools: new Set(liveTools.map((tool) => tool.name)),
2625
2752
  });
2753
+ await cacheLastSuccessfulTools(record, liveTools);
2754
+ cached = cachedToolsForRecord(record);
2626
2755
  }
2756
+ const cachedFallback = liveTools.length === 0 ? cached : null;
2757
+ const displayTools = liveTools.length > 0
2758
+ ? liveTools
2759
+ : cachedFallback?.entry.tools?.length > 0
2760
+ ? cachedFallback.entry.tools
2761
+ : snapshot?.tools ?? [];
2627
2762
  const diagnosticCode = registered.error
2628
2763
  ? 'host-status-error'
2629
2764
  : registered.supported ? 'host-tools-pending' : 'host-status-unavailable';
@@ -2633,17 +2768,23 @@ export async function apply(ctx, config) {
2633
2768
  ok: liveTools.length > 0,
2634
2769
  managed: true,
2635
2770
  live: liveTools.length > 0,
2636
- preview: liveTools.length === 0 && Boolean(snapshot),
2771
+ cached: Boolean(cachedFallback),
2772
+ preview: liveTools.length === 0 && !cachedFallback && Boolean(snapshot),
2637
2773
  unknown: liveTools.length === 0,
2638
2774
  diagnosticCode,
2639
2775
  policy: serverPolicy(record),
2640
- tools: (liveTools.length > 0
2641
- ? liveTools
2642
- : (snapshot?.tools ?? []).map((tool) => ({
2643
- name: tool.name,
2644
- title: tool.title || tool.name,
2645
- description: tool.description || '',
2646
- }))).map((tool) => decorateToolPolicy(record, tool, observedNames)),
2776
+ tools: displayTools.map((tool) => decorateToolPolicy(record, {
2777
+ name: tool.name,
2778
+ ...(tool.publicName ? { publicName: tool.publicName } : {}),
2779
+ title: tool.title || tool.name,
2780
+ description: tool.description || '',
2781
+ }, observedNames)),
2782
+ ...(cachedFallback ? {
2783
+ cacheObservedAt: cachedFallback.observedAt,
2784
+ cacheAgeMs: cachedFallback.cacheAgeMs,
2785
+ stale: cachedFallback.stale,
2786
+ cacheTruncated: cachedFallback.entry.truncated === true,
2787
+ } : {}),
2647
2788
  ...(liveTools.length === 0 ? {
2648
2789
  errorKind: registered.supported ? 'startup' : 'host-version',
2649
2790
  error: registered.supported
@@ -2663,6 +2804,7 @@ export async function apply(ctx, config) {
2663
2804
  observedAt: Date.now(),
2664
2805
  tools: new Set(listed.tools.map((tool) => tool.name)),
2665
2806
  });
2807
+ await cacheLastSuccessfulTools(record, listed.tools);
2666
2808
  servers.push({
2667
2809
  serverKey: record.serverKey || record.name,
2668
2810
  serverName: record.serverName,
@@ -2675,6 +2817,19 @@ export async function apply(ctx, config) {
2675
2817
  }, observedNames)),
2676
2818
  });
2677
2819
  } catch (error) {
2820
+ const cached = cachedToolsForRecord(record);
2821
+ const cachedFields = cached ? {
2822
+ cached: true,
2823
+ cacheObservedAt: cached.observedAt,
2824
+ cacheAgeMs: cached.cacheAgeMs,
2825
+ stale: cached.stale,
2826
+ cacheTruncated: cached.entry.truncated === true,
2827
+ tools: cached.entry.tools.map((tool) => decorateToolPolicy(record, {
2828
+ name: tool.name,
2829
+ title: tool.title || tool.name,
2830
+ description: tool.description || '',
2831
+ }, observedNames)),
2832
+ } : { tools: [] };
2678
2833
  if (error instanceof McpHttpError) {
2679
2834
  servers.push({
2680
2835
  serverKey: record.serverKey || record.name,
@@ -2683,12 +2838,12 @@ export async function apply(ctx, config) {
2683
2838
  error: error.message,
2684
2839
  errorKind: error.kind,
2685
2840
  policy: serverPolicy(record),
2686
- tools: [],
2841
+ ...cachedFields,
2687
2842
  });
2688
2843
  continue;
2689
2844
  }
2690
2845
  const classified = classifyConnectionError(error);
2691
- servers.push({ serverKey: record.serverKey || record.name, serverName: record.serverName, ok: false, error: classified.message, errorKind: classified.kind, policy: serverPolicy(record), tools: [] });
2846
+ servers.push({ serverKey: record.serverKey || record.name, serverName: record.serverName, ok: false, error: classified.message, errorKind: classified.kind, policy: serverPolicy(record), ...cachedFields });
2692
2847
  }
2693
2848
  }
2694
2849
 
@@ -2697,6 +2852,7 @@ export async function apply(ctx, config) {
2697
2852
  const unknownServers = servers.filter((server) => server.unknown).length;
2698
2853
  const failedServers = servers.length - availableServers - unknownServers;
2699
2854
  const pendingManagedServers = unknownServers;
2855
+ const cachedServers = servers.filter((server) => server.cached).length;
2700
2856
  const health = cacheHealth(connectorId, records, servers.map((server) => server.ok
2701
2857
  ? { ok: true, kind: 'connected', serverKey: server.serverKey, serverName: server.serverName }
2702
2858
  : server.unknown
@@ -2716,7 +2872,7 @@ export async function apply(ctx, config) {
2716
2872
  return {
2717
2873
  ok: availableServers > 0,
2718
2874
  message: failedServers > 0
2719
- ? `工具加载失败:${failureSummary}${failedServers > 3 ? `;另有 ${failedServers - 3} 个 Server 不可用` : ''}${availableServers > 0 ? `;${availableServers} 个 Server 仍可用` : ''}`
2875
+ ? `工具加载失败:${failureSummary}${failedServers > 3 ? `;另有 ${failedServers - 3} 个 Server 不可用` : ''}${availableServers > 0 ? `;${availableServers} 个 Server 仍可用` : ''}${cachedServers > 0 ? `;${cachedServers} 个 Server 显示最后成功缓存` : ''}`
2720
2876
  : unknownServers > 0
2721
2877
  ? `工具状态未知:${unknownSummary}${unknownServers > 3 ? `;另有 ${unknownServers - 3} 个 Server 尚未确认` : ''}`
2722
2878
  : `共 ${servers.length} 个 server,${totalTools} 个工具`,
@@ -2728,6 +2884,7 @@ export async function apply(ctx, config) {
2728
2884
  failedServers,
2729
2885
  unknownServers,
2730
2886
  pendingManagedServers,
2887
+ cachedServers,
2731
2888
  connectionState: health.connectionState,
2732
2889
  lastSuccessfulAt: health.lastSuccessfulAt,
2733
2890
  diagnostic: health.diagnostic,
@@ -2755,6 +2912,19 @@ export async function apply(ctx, config) {
2755
2912
  logger.warn(`skip invalid stored connection "${key}": ${error.message}`);
2756
2913
  }
2757
2914
  }
2915
+ if (config.persistSecrets) {
2916
+ for (const [key, cached] of await toolCatalogStore.entries().catch(() => [])) {
2917
+ const record = state.connections.get(key);
2918
+ if (record
2919
+ && cached.connectorId === record.connectorId
2920
+ && cached.serverName === record.serverName
2921
+ && cached.connectionSignature === connectionToolCacheSignature(record)) {
2922
+ state.toolCatalogCache.set(key, cached);
2923
+ } else {
2924
+ await toolCatalogStore.delete(key).catch(() => {});
2925
+ }
2926
+ }
2927
+ }
2758
2928
  for (const [key, raw] of await grantStore.entries().catch(() => [])) {
2759
2929
  try {
2760
2930
  state.grants.set(key, grantRuntime(raw));
@@ -2812,7 +2982,8 @@ export async function apply(ctx, config) {
2812
2982
  hostGovernance.refresh();
2813
2983
  hostScopes.refresh();
2814
2984
 
2815
- const disposeTools = registerTools(ctx, api);
2985
+ const observableApi = observeStatusEvents(api, statusEvents);
2986
+ const disposeTools = registerTools(ctx, observableApi);
2816
2987
 
2817
2988
  /* ───────────────────────── web 半区(图形化市场)──────────────────────── */
2818
2989
  // webServer / webRuntime 仅存在于 web 部署;无此服务时静默跳过(纯对话工具场景仍可用)。
@@ -2820,7 +2991,7 @@ export async function apply(ctx, config) {
2820
2991
  if (typeof ctx.inject === 'function') {
2821
2992
  try {
2822
2993
  ctx.inject(['webServer', 'webRuntime'], (wctx) => {
2823
- disposeWeb = mountWebRoutes(wctx, api, { logger });
2994
+ disposeWeb = mountWebRoutes(wctx, observableApi, { logger, eventHub: statusEvents });
2824
2995
  logger.info('mcp-connector web UI mounted at /mcp-connector/ui/');
2825
2996
  });
2826
2997
  } catch (error) {
@@ -2833,6 +3004,7 @@ export async function apply(ctx, config) {
2833
3004
  disposeWeb?.();
2834
3005
  hostGovernance.dispose();
2835
3006
  hostScopes.dispose();
3007
+ statusEvents.dispose();
2836
3008
  versionStatusService.dispose();
2837
3009
  for (const g of state.grants.values()) if (g.timer) clearTimeout(g.timer);
2838
3010
  });
package/lib/schema.js CHANGED
@@ -190,6 +190,28 @@ export const connectionScopeDocumentSchema = z.object({
190
190
  history: z.array(connectionScopeRevisionSchema),
191
191
  });
192
192
 
193
+ /**
194
+ * 最近一次成功 tools/list 的安全能力快照。
195
+ * 这里只允许工具元数据与经过裁剪的输入 schema;调用参数、调用结果、凭据和原始错误均不入库。
196
+ */
197
+ export const toolCatalogRecordSchema = z.object({
198
+ key: z.string().min(1).max(512),
199
+ connectorId: z.string().min(1).max(256),
200
+ serverKey: z.string().min(1).max(256).optional(),
201
+ serverName: z.string().min(1).max(256),
202
+ connectionSignature: z.string().regex(/^[a-f0-9]{64}$/),
203
+ observedAt: z.number().int().nonnegative(),
204
+ updatedAt: z.number().int().nonnegative(),
205
+ truncated: z.boolean().optional(),
206
+ tools: z.array(z.object({
207
+ name: z.string().min(1).max(512),
208
+ title: z.string().max(1024).optional(),
209
+ description: z.string().max(4096).optional(),
210
+ inputSchema: z.unknown().optional(),
211
+ schemaTruncated: z.boolean().optional(),
212
+ })).max(512),
213
+ });
214
+
193
215
  /** OAuth 授权记录(通用多厂商) */
194
216
  export const grantRecordSchema = z.object({
195
217
  key: z.string().min(1),