dsh-mcp-connector 0.2.77 → 0.2.79

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
@@ -2,6 +2,32 @@
2
2
 
3
3
  本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/);首个公开版本从 `0.1.0` 开始。
4
4
 
5
+ ## [0.2.79] - 2026-10-24
6
+
7
+ ### Fixed
8
+
9
+ - #178:治理、项目作用域和会话注入刷新改为轻量名称读取,不再调用 tools.schemas() 深克隆参数,切断 schema 读取副作用引起的延迟事件回路。
10
+ - 默认全局/always 且无 deny 或停用连接时跳过清单读取;项目隔离每轮只读取一次,不按 Agent 数重复。
11
+ - 保留执行 guard、工具增删感知和权限交集;名称读取失败时保留已有限制,不把错误当空清单,也不回退 schema 扫描。
12
+
13
+ ### Compatibility and verification
14
+
15
+ - 优先使用上游公开 definitions();已发布旧 Host 通过集中、只读、形状校验的内部 view() 兼容层读取名称。内部接口属于兼容风险,增加 Windows/Linux × Host 0.2.1-rc.2/rc.3 契约门禁;不修改宿主安装。
16
+ - 增加同步、微任务、下一轮事件交付及残余 schema 反馈回路回归。详见 docs/ACCEPTANCE-178-20261024.md。
17
+ - 本次是工具运行时机制修复;报告者 Windows Desktop UI 的原始卡顿与 1.29 亿次调用对照仍需复测,#178 保留跟踪。不包含延期的国际化或其他草案功能。
18
+
19
+ ## [0.2.78] - 2026-10-22
20
+
21
+ ### Fixed
22
+
23
+ - 将版本检查 API 的 401、403、404、5xx 和异常 JSON 响应区分为可操作的提示,避免把所有失败笼统显示为插件 API 不可用;不向界面回显代理正文或敏感响应信息。
24
+ - 补充 NAS / 反向代理诊断指引和回归测试,保留 Host/Origin 安全边界。
25
+
26
+ ### Scope
27
+
28
+ - 本版本改进 #172 的诊断,不宣称已修复或实机验证飞牛 NAS 的部署路由问题;该 Issue 保持开放。
29
+ - 不包含暂缓的国际化 PR #174 或待验收的启动加固 PR #168。
30
+
5
31
  ## [0.2.77] - 2026-10-22
6
32
 
7
33
  ### Security
package/README.en.md CHANGED
@@ -225,7 +225,7 @@ npm run dev:ui
225
225
 
226
226
  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.
227
227
 
228
- The current public version is [`dsh-mcp-connector@0.2.77`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.77](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.77).
228
+ The current public version is [`dsh-mcp-connector@0.2.79`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.79](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.79).
229
229
 
230
230
  See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
231
231
 
package/README.md CHANGED
@@ -220,7 +220,7 @@ npm run dev:ui
220
220
 
221
221
  公共 Registry 每次合并后会生成 `catalog-stats.json`;本仓库的定时工作流每小时同步中英文介绍和统计快照。npm 页面中的静态正文随版本发布更新,上方动态统计徽标则直接读取 Registry,可在不发布新 npm 版本时保持实时数量一致。
222
222
 
223
- 当前公开版本为 [`dsh-mcp-connector@0.2.77`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.77](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.77)。
223
+ 当前公开版本为 [`dsh-mcp-connector@0.2.79`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.79](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.79)。
224
224
 
225
225
  版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
226
226
  Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
@@ -384,6 +384,18 @@ Windows 下 `.cmd` / `.bat` 是脚本包装器,不等同于可直接启动的
384
384
 
385
385
  修正后重新启动 DSH(如变更了进程环境),在连接详情点击“重新检查”,确认工具发现结果。反馈时只提交版本、脱敏后的诊断代码、退出码和必要日志;不要上传 Token、API Key、完整环境变量或个人路径。
386
386
 
387
+ ### NAS / 反向代理下版本检查 API 不可用
388
+
389
+ 顶部版本检查失败不等于全部连接器失效,也不等于 npm 故障。先检查浏览器开发者工具中同源 `POST /mcp-connector/api`(`method: versionStatus`)的 HTTP 状态:
390
+
391
+ - **401**:确认 DSH 登录会话有效,重新登录后重试。
392
+ - **403**:核对宿主声明的可信访问地址(含端口)及代理转发的 Host/Origin;不要关闭校验、信任任意来源或自动放行整个局域网。
393
+ - **404**:检查插件路由是否挂载,反向代理是否正确转发 `/mcp-connector/api`。
394
+ - **5xx**:查看 DSH 和代理服务日志。
395
+ - **200 但不是正确 JSON**:可能返回了登录页或代理兜底页面,检查路由和登录状态。
396
+
397
+ 上述状态只能缩小排查范围,不能单独证明根因。反馈时提供 DSH/插件版本、Docker 或原生部署、IP/域名/反向代理访问方式、HTTP 状态和响应内容类型;保持脱敏后 Host/Origin 的协议与端口关系。不要公开 Token、Cookie、完整 HAR、完整响应正文或个人网络地址。
398
+
387
399
  ### 为什么显示“状态未知”
388
400
 
389
401
  这表示插件没有足够证据确认健康或失败,常见于 DSH 刚重启、尚未执行健康检查,或当前 Host 无法读取 stdio 工具注册状态。先点击“刷新连接器目录”;若仍为未知,在该连接(或详情页)点“重新连接”重建 Host 侧条目——Host 的 mcp-client 在连续重连失败后会注销工具且不再自发重试,只有重建条目才能恢复。若重建后依旧未知,再根据诊断的 `code` 和建议查看 Host 日志或升级 Host。不要把“状态未知”理解为“已连接”。
package/lib/client.js CHANGED
@@ -463,15 +463,28 @@ window.__ModuleLoader__.load({
463
463
  return mount;
464
464
  }
465
465
 
466
+ function versionApiFailureMessage(status) {
467
+ const prefix = "插件版本检查 API 不可用";
468
+ if (status === 401) return `${prefix}(HTTP 401):请重新登录 DSH 后重试`;
469
+ if (status === 403) return `${prefix}(HTTP 403):请检查 DSH 可信访问地址及反向代理 Host/Origin 配置,不要关闭安全校验`;
470
+ if (status === 404) return `${prefix}(HTTP 404):请检查插件路由是否加载,以及反向代理是否转发 /mcp-connector/api`;
471
+ if (Number.isInteger(status) && status >= 400 && status <= 599) return `${prefix}(HTTP ${status}):请检查 DSH 服务和反向代理日志`;
472
+ return `${prefix}:响应格式不正确,请检查登录状态、反向代理和插件版本兼容性`;
473
+ }
474
+
466
475
  async function fetchVersionStatus(force = false) {
467
476
  const response = await window.fetch("/mcp-connector/api", {
468
477
  method: "POST",
469
478
  headers: { "content-type": "application/json" },
470
479
  body: JSON.stringify({ method: "versionStatus", params: { force } })
471
480
  });
472
- const result = await response.json();
473
- if (!response.ok || result.ok !== true || result.detail === void 0) {
474
- throw new Error(result.message ?? `HTTP ${response.status}`);
481
+ // Classify HTTP errors before parsing: a proxy may return an HTML login/error page.
482
+ if (!response.ok) throw new Error(versionApiFailureMessage(response.status));
483
+ let result;
484
+ try { result = await response.json(); }
485
+ catch { throw new Error(versionApiFailureMessage()); }
486
+ if (result?.ok !== true || !result.detail || typeof result.detail !== "object" || Array.isArray(result.detail)) {
487
+ throw new Error(versionApiFailureMessage());
475
488
  }
476
489
  return result.detail;
477
490
  }
@@ -1207,7 +1220,9 @@ window.__ModuleLoader__.load({
1207
1220
  console.warn("[mcp-connector] plugin update check failed:", error);
1208
1221
  if (!disposed) {
1209
1222
  setVersionCheckBusy(false);
1210
- setVersionCheckFailed("api");
1223
+ // Only expose our fixed diagnostic text; never echo proxy bodies or URLs/tokens.
1224
+ setVersionCheckFailed(error instanceof Error && error.message.startsWith("插件版本检查 API 不可用")
1225
+ ? error.message : "插件版本检查 API 请求失败:请检查网络、DSH 服务和反向代理连接");
1211
1226
  schedule(VERSION_CHECK_RETRY_MS);
1212
1227
  }
1213
1228
  });
@@ -1668,7 +1683,7 @@ window.__ModuleLoader__.load({
1668
1683
  versionCheckFailed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1669
1684
  className: "mcpConnectorUpdateStatus",
1670
1685
  "data-tone": "error",
1671
- children: versionCheckFailed === "api" ? "插件 API 不可用,请检查 DSH 宿主兼容性或服务日志" : "暂时无法确认 npm 最新版本,请重试"
1686
+ children: typeof versionCheckFailed === "string" && versionCheckFailed !== "npm" ? versionCheckFailed : "暂时无法确认 npm 最新版本,请重试"
1672
1687
  }),
1673
1688
  versionStatus?.releasePending && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1674
1689
  className: "mcpConnectorVersion",
@@ -7,6 +7,8 @@
7
7
  * credential.
8
8
  */
9
9
 
10
+ import { readHostToolNames, supportsHostToolNames } from './host-tool-names.js';
11
+
10
12
  export const CONNECTION_SCOPE_HISTORY_LIMIT = 20;
11
13
  export const CONNECTION_SCOPE_TYPES = ['global', 'project'];
12
14
 
@@ -280,19 +282,18 @@ export function createHostScopeController(ctx, {
280
282
  : null;
281
283
 
282
284
  function observedPublicNames() {
283
- if (typeof ctx.tools?.schemas !== 'function') return [];
284
285
  try {
285
- return ctx.tools.schemas().map((schema) => schema?.name)
286
+ return readHostToolNames(ctx.tools)
286
287
  .filter((name) => typeof name === 'string' && recordForPublicName(name))
287
288
  .sort();
288
289
  } catch (error) {
289
290
  logger?.warn?.(`scope read Host tools failed: ${error.message}`);
290
- return [];
291
+ return null;
291
292
  }
292
293
  }
293
294
 
294
- function installRestriction(agent) {
295
- const denied = observedPublicNames().filter((name) => {
295
+ function installRestriction(agent, observed) {
296
+ const denied = observed.filter((name) => {
296
297
  const record = recordForPublicName(name);
297
298
  return record && !visible(record, agent);
298
299
  });
@@ -315,15 +316,21 @@ export function createHostScopeController(ctx, {
315
316
  let syncing = false;
316
317
  function refresh() {
317
318
  if (disposed || !agentsMounted) return;
318
- // Reading lazy Host schemas or replacing a restriction may synchronously
319
- // emit tools/change. Ignore that nested notification: replaying it scans
320
- // the same single-threaded state and can otherwise keep the event loop in
321
- // a permanent tools/change cycle with plugins that re-register tools.
319
+ // Ignore synchronous restriction notifications while reconciling. Name-only
320
+ // reads avoid schema side effects; delayed events still observe real changes.
322
321
  if (syncing) return;
323
322
  syncing = true;
324
323
  try {
325
324
  const live = new Set(mountedAgentCtx?.agents?.list?.() ?? []);
326
- for (const agent of live) installRestriction(agent);
325
+ // Global bindings cannot hide tools. When project isolation is needed,
326
+ // enumerate once per refresh, not once per live Agent.
327
+ const currentBindings = bindings();
328
+ const needsRestriction = records().some((record) => (
329
+ !bindingForConnection(currentBindings, record.key).global
330
+ ));
331
+ const observed = live.size && needsRestriction ? observedPublicNames() : [];
332
+ if (observed === null) return;
333
+ for (const agent of live) installRestriction(agent, observed);
327
334
  for (const agent of restrictions.keys()) if (!live.has(agent)) restrictions.delete(agent);
328
335
  } finally {
329
336
  syncing = false;
@@ -363,7 +370,7 @@ export function createHostScopeController(ctx, {
363
370
  capabilities() {
364
371
  return {
365
372
  executionGuard: Boolean(disposeGuard),
366
- visibilityRestriction: agentsMounted && typeof ctx.tools?.restrict === 'function',
373
+ visibilityRestriction: agentsMounted && typeof ctx.tools?.restrict === 'function' && supportsHostToolNames(ctx.tools),
367
374
  workspaceRegistry: typeof workspaceIdForAgent === 'function',
368
375
  restrictedAgents: restrictions.size,
369
376
  };
package/lib/governance.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * 连接记录的 lifecycle disabled 是物理下线状态,任何 allow 规则都不能覆盖。
6
6
  */
7
7
  import { createHash } from 'node:crypto';
8
+ import { readHostToolNames, supportsHostToolNames } from './host-tool-names.js';
8
9
 
9
10
  export const GOVERNANCE_HISTORY_LIMIT = 20;
10
11
  export const GOVERNANCE_SCOPES = ['connection', 'server', 'tool'];
@@ -302,20 +303,23 @@ export function createHostGovernanceController(ctx, { getRules, getRecords, logg
302
303
  : null;
303
304
 
304
305
  function observedPublicNames() {
305
- if (typeof ctx.tools?.schemas !== 'function') return [];
306
306
  try {
307
- return ctx.tools.schemas()
308
- .map((schema) => schema?.name)
307
+ return readHostToolNames(ctx.tools)
309
308
  .filter((name) => typeof name === 'string' && targetForPublicName(name))
310
309
  .sort();
311
310
  } catch (error) {
312
311
  logger?.warn?.(`governance read Host tools failed: ${error.message}`);
313
- return [];
312
+ return null;
314
313
  }
315
314
  }
316
315
 
317
316
  function deniedObservedNames() {
318
- return observedPublicNames().filter((name) => inspect(name)?.policy.effect === 'deny');
317
+ // The default policy cannot deny anything. Avoid deep-cloning every Host
318
+ // schema just to prove that the resulting restriction would be empty.
319
+ // Disabled connections still require enforcement even without a deny rule.
320
+ if (!records().some((record) => record.enabled === false)
321
+ && !rules().some((rule) => rule.effect === 'deny')) return [];
322
+ return observedPublicNames()?.filter((name) => inspect(name)?.policy.effect === 'deny') ?? null;
319
323
  }
320
324
 
321
325
  function installRestriction(agent, denied) {
@@ -338,16 +342,15 @@ export function createHostGovernanceController(ctx, { getRules, getRecords, logg
338
342
  let syncing = false;
339
343
  function syncRestrictions() {
340
344
  if (disposed || !agentsMounted) return;
341
- // schemas()/restrict() may synchronously cause another tools/change while
342
- // Host plugins materialize lazy schemas or re-register equivalent tools.
343
- // JavaScript cannot interleave an unrelated external change in this stack,
344
- // so replaying that nested event only scans the same state again and can
345
- // form an endless tools/change -> schemas -> tools/change loop.
345
+ // Restriction installation may synchronously notify the same controller.
346
+ // Name-only reads below do not materialize schemas; delayed notifications
347
+ // are harmless rechecks and identical deny sets do not reinstall a mask.
346
348
  if (syncing) return;
347
349
  syncing = true;
348
350
  try {
349
- const denied = deniedObservedNames();
350
351
  const live = new Set(mountedAgentCtx?.agents?.list?.() ?? []);
352
+ const denied = live.size ? deniedObservedNames() : [];
353
+ if (denied === null) return;
351
354
  for (const agent of live) installRestriction(agent, denied);
352
355
  for (const agent of agentRestrictions.keys()) {
353
356
  if (!live.has(agent)) agentRestrictions.delete(agent);
@@ -385,7 +388,7 @@ export function createHostGovernanceController(ctx, { getRules, getRecords, logg
385
388
  capabilities() {
386
389
  return {
387
390
  executionGuard: Boolean(disposeGuard),
388
- visibilityRestriction: agentsMounted && typeof ctx.tools?.restrict === 'function',
391
+ visibilityRestriction: agentsMounted && typeof ctx.tools?.restrict === 'function' && supportsHostToolNames(ctx.tools),
389
392
  activeAgents: mountedAgentCtx?.agents?.list?.().length ?? 0,
390
393
  restrictedAgents: agentRestrictions.size,
391
394
  };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Name-only Host adapter. Never materialize schemas on the visibility event path.
3
+ *
4
+ * Upstream exposes definitions() on main. Published Host 0.1.2-rc.1 and
5
+ * 0.2.1-rc.2/rc.3 instead expose view() at runtime (private in their typings).
6
+ * Keep that legacy dependency here, read-only and shape-checked; never patch
7
+ * the Host or retain its definitions. Pinned real-Host tests cover the bridge.
8
+ */
9
+ export function supportsHostToolNames(tools) {
10
+ return typeof tools?.definitions === 'function' || typeof tools?.view === 'function';
11
+ }
12
+
13
+ export function readHostToolNames(tools) {
14
+ let names;
15
+ if (typeof tools?.definitions === 'function') {
16
+ const definitions = tools.definitions();
17
+ if (!Array.isArray(definitions)) throw new Error('Host tools.definitions() 返回格式不兼容');
18
+ names = definitions.map((definition) => definition?.name);
19
+ } else if (typeof tools?.view === 'function') {
20
+ const view = tools.view();
21
+ if (!(view?.visible instanceof Map)
22
+ || !(view.knownNames instanceof Set)
23
+ || !(view.restrictableNames instanceof Set)) {
24
+ throw new Error('Host tools.view() 返回格式不兼容');
25
+ }
26
+ names = [...view.visible.keys()];
27
+ } else {
28
+ // A schema fallback would reintroduce #178. Callers retain existing masks
29
+ // on error and their independent execution guards remain active.
30
+ throw new Error('当前 Host 不支持轻量工具名称读取,无法刷新工具可见性限制');
31
+ }
32
+ if (names.some((name) => typeof name !== 'string' || !name)) {
33
+ throw new Error('Host 工具名称格式不兼容');
34
+ }
35
+ return [...new Set(names)].sort();
36
+ }
@@ -7,6 +7,8 @@
7
7
  * another conversation or survive a DSH restart.
8
8
  */
9
9
 
10
+ import { readHostToolNames, supportsHostToolNames } from './host-tool-names.js';
11
+
10
12
  export const INJECTION_MODES = ['always', 'session'];
11
13
  export const DEFAULT_SESSION_TOOL_TTL_MINUTES = 30;
12
14
  export const MAX_SESSION_TOOL_TTL_MINUTES = 30;
@@ -68,14 +70,13 @@ export function createSessionToolInjectionController(ctx, {
68
70
  }
69
71
 
70
72
  function observedPublicNames() {
71
- if (typeof ctx.tools?.schemas !== 'function') return [];
72
73
  try {
73
- return ctx.tools.schemas().map((schema) => schema?.name)
74
+ return readHostToolNames(ctx.tools)
74
75
  .filter((name) => typeof name === 'string' && recordForPublicName(name))
75
76
  .sort();
76
77
  } catch (error) {
77
78
  logger?.warn?.(`session injection read Host tools failed: ${error.message}`);
78
- return [];
79
+ return null;
79
80
  }
80
81
  }
81
82
 
@@ -165,9 +166,15 @@ export function createSessionToolInjectionController(ctx, {
165
166
  if (disposed || !agentsMounted || syncing) return;
166
167
  syncing = true;
167
168
  try {
168
- const observed = observedPublicNames();
169
- const observedSet = new Set(observed);
170
169
  const live = new Set(mountedAgentCtx?.agents?.list?.() ?? []);
170
+ // Legacy/default always-on connections need no session visibility mask.
171
+ // Still reconcile empty masks and purge activations when the mode changes.
172
+ const needsRestriction = records().some((record) => (
173
+ record.enabled !== false && normalizeInjectionMode(record.injectionMode) === 'session'
174
+ ));
175
+ const observed = live.size && needsRestriction ? observedPublicNames() : [];
176
+ if (observed === null) return;
177
+ const observedSet = new Set(observed);
171
178
  for (const agent of live) {
172
179
  purgeState(agent, observedSet);
173
180
  installRestriction(agent, deniedNames(agent, observed));
@@ -223,7 +230,9 @@ export function createSessionToolInjectionController(ctx, {
223
230
  if (!isRecordVisible(record, agent)) throw new Error(`连接 "${record.name}" 不属于当前工作区`);
224
231
  const requested = uniqueNames(input.publicNames);
225
232
  if (requested.length === 0) throw new Error('必须指定至少一个精确 public tool name');
226
- const observed = new Set(observedPublicNames());
233
+ const names = observedPublicNames();
234
+ if (names === null) throw new Error('Host 工具名称读取失败,暂不能激活工具');
235
+ const observed = new Set(names);
227
236
  for (const publicName of requested) {
228
237
  if (!observed.has(publicName)) throw new Error(`Host 尚未观察到工具 "${publicName}"`);
229
238
  const owner = recordForPublicName(publicName);
@@ -249,7 +258,9 @@ export function createSessionToolInjectionController(ctx, {
249
258
 
250
259
  function status(agent) {
251
260
  assertAgent(agent);
252
- const observed = new Set(observedPublicNames());
261
+ const names = observedPublicNames();
262
+ if (names === null) throw new Error('Host 工具名称读取失败,暂不能读取会话工具状态');
263
+ const observed = new Set(names);
253
264
  purgeState(agent, observed);
254
265
  const state = stateFor(agent);
255
266
  const active = [...(state?.activations ?? new Map()).entries()].map(([publicName, activation]) => ({
@@ -375,7 +386,7 @@ export function createSessionToolInjectionController(ctx, {
375
386
  function capabilities() {
376
387
  return {
377
388
  executionGuard: Boolean(disposeGuard),
378
- visibilityRestriction: agentsMounted && typeof ctx.tools?.restrict === 'function',
389
+ visibilityRestriction: agentsMounted && typeof ctx.tools?.restrict === 'function' && supportsHostToolNames(ctx.tools),
379
390
  activeAgents: mountedAgentCtx?.agents?.list?.().length ?? 0,
380
391
  restrictedAgents: restrictions.size,
381
392
  maxTtlMinutes: MAX_SESSION_TOOL_TTL_MINUTES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp-connector",
3
- "version": "0.2.77",
3
+ "version": "0.2.79",
4
4
  "description": "DeepSeek Harness MCP Connector: connect servers, search tools across active connections, and troubleshoot discovery. Includes a continuously updated catalog; supports OAuth 2.0 PKCE, API keys, Streamable HTTP/stdio, and mcpServers JSON import.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",