dsh-mcp 1.11.0 → 1.11.1

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.en.md CHANGED
@@ -7,6 +7,19 @@ All notable changes to this project are documented in this file.
7
7
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
8
8
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
9
9
 
10
+ ## Compatibility
11
+
12
+ - **Supported DSH version**: **dsh `0.1.5-rc.1`** — developed and verified on `0.1.5-rc.1` (declared in `package.json` → `dsh.supported`). When the DSH and plugin versions do not match, the Settings page reports a diagnosis (check the `cordis.patch.yml` row → restart `dsh web` → hard-refresh → upgrade both sides).
13
+ - **Release convention**: every version entry states `- **Supported DSH version**: dsh <version>`, mirrored in the GitHub Release notes.
14
+
15
+ ## [1.11.1] - 2026-09-13
16
+
17
+ ### Fixed
18
+
19
+ - **OAuth is now an explicit switch instead of a header guess (no more repeated browser authorizations)**: the previous rule was "streamable-http without an `Authorization` header gets an OAuth provider", so servers that authenticate with static tokens or custom headers (`x-bbzai-mcp-token`, `X-Mcp-Token`, `Private-Token`) were treated as OAuth servers. When such a server answered 401 (a stale token, a gateway challenge), the SDK asked for authorization, the plugin opened the browser and started its loopback callback (`127.0.0.1:<port>/callback`) — a flow that could never satisfy those servers, so every reconnect or remount opened the browser again. A stored server now has an **`oauth: true`** switch (the form's "Use OAuth authorization" checkbox, off by default; taking over a declaration without an `Authorization` header ticks it, and it stays editable)
20
+ - **Upgrade migration**: servers that already hold OAuth credentials (a token or a client registration) keep `oauth: true`, everything else is turned off — so servers that genuinely need OAuth keep working while the misdetected ones stop opening the browser
21
+ - **Automatic-authorization circuit breaker**: the plugin opens the browser **at most once per server per process**; later automatic attempts return the authorization-link error instead (the 1.8.0 behaviour), and "Test connection" can still start an authorization on demand
22
+
10
23
  ## [1.11.0] - 2026-09-13
11
24
 
12
25
  ### Added
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@
7
7
  格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
8
8
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
9
9
 
10
+ ## 兼容性
11
+
12
+ - **支持版本**:**dsh `0.1.5-rc.1`** —— 本插件在 `0.1.5-rc.1` 上开发与验证(`package.json` → `dsh.supported` 同步声明)。DSH 与插件版本不匹配时,设置页会给出排查诊断(核对 `cordis.patch.yml` 注册行 → 重启 `dsh web` → 硬刷新 → 同步升级)。
13
+ - **发版约定**:每个版本条目都要声明 `- **支持版本**:dsh <版本>`,并在 GitHub Release notes 中同步。
14
+
15
+ ## [1.11.1] - 2026-09-13
16
+
17
+ ### 修复
18
+
19
+ - **OAuth 改为显式开关,不再凭请求头猜测(停止反复弹出浏览器授权)**:此前规则是"streamable-http 且没有 `Authorization` 头 → 挂 OAuth provider",于是用静态 token/自定义头(`x-bbzai-mcp-token`、`X-Mcp-Token`、`Private-Token`)认证的服务器也被当成 OAuth 服务器。这类服务器返回 401 时(token 失效、网关挑战等),SDK 会要求授权 → 插件打开浏览器并起回环回调(`127.0.0.1:<port>/callback`),而该流程永远无法让它们通过 → 每次重连/重挂载就再弹一次。现在服务器配置新增 **`oauth: true`** 开关(设置页勾选「使用 OAuth 授权」,默认关;接管声明时若该声明没有 `Authorization` 头则默认勾上,之后仍可修改)
20
+ - **升级自动迁移**:已存在 OAuth 凭据(token 或 client 注册记录)的服务器会保留 `oauth: true`,其余一律关闭——因此**现有真正需要 OAuth 的服务器不受影响,误判的那批立刻停止弹窗**
21
+ - **自动弹窗熔断**:同一服务器**每个进程最多自动打开一次**浏览器授权;之后的自动尝试不再弹窗,改为返回带授权链接的错误(沿用 1.8.0 的能力),手动点「测试连接」仍可随时发起授权
22
+
10
23
  ## [1.11.0] - 2026-09-13
11
24
 
12
25
  ### 新增
package/README.en.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [![dshfind](https://dshfind.com/api/badge/ArvinQi/dsh-mcp?lang=en)](https://dshfind.com/en/plugins/ArvinQi/dsh-mcp?ref=badge)
4
4
 
5
+ > **Supported DSH version**: `dsh 0.1.5-rc.1` — developed and verified on `0.1.5-rc.1`, declared in `package.json` → `dsh.supported`. When the DSH and plugin versions do not match, the Settings page shows a diagnosis (check the registration row → restart `dsh web` → hard-refresh → upgrade both sides).
6
+
5
7
  ![Settings preview](static/snapshot.en.webp)
6
8
 
7
9
  ## Why dsh-mcp?
@@ -78,6 +80,7 @@ Since 1.11.0 those declarations appear in Settings → MCP, badged "cordis decla
78
80
  - **Imported into storages as mirrors**: at startup and on every refresh the declarations are imported into the storage domain (id `cordis:<rowId>`, with `origin/declaredIn/declaredRowId`), **import only** — a row created in the plugin is never overwritten; a mirror whose declaration disappeared is **removed automatically** (a mirror is only a copy of a declaration, so an ownerless one must not pile up); a declaration containing a `!!js` expression is skipped and flagged with the reason (its value only resolves inside the Loader). Mirrors are never mounted — the composition owns the mount.
79
81
  - **Take over / give back**: clicking "Take over" makes the plugin write an id-targeted `disabled: true` into its own **managed block** in `cordis.patch.yml` (a `.dsh-mcp.bak` backup is taken before the first write; atomic, idempotent, reversible, confined to that block). The declaration releases the `serverName` and the plugin mounts its own row instead — enabling **OAuth authorization, managed credentials, `${VAR}` header substitution and connection tests**. A hard mount failure rolls back automatically (the block is removed and the row returns to mirror state) and reports why; "Give back" hands the mount back to the declaration and deletes the managed row. A declaration without an explicit `id`, or one containing a `!!js` expression, cannot be taken over.
80
82
  - **OAuth / placeholder limit (`needsPlugin`)**: a declaration can carry neither an OAuth provider nor `${VAR}` / bare-name placeholder substitution — only the plugin does those. Such a declaration can therefore only work while the plugin owns the mount: the page reports it as **failed with the reason** (not "connecting"), "Give back" makes its tools disappear (the confirmation and the result warning both say so), and taking it over again restores them.
83
+ - **OAuth is an explicit switch (since 1.11.1)**: an OAuth provider is attached only when the server sets `oauth: true` (the form's "Use OAuth authorization" checkbox, off by default). **Do not tick it for servers that authenticate with static tokens or custom headers** — otherwise a 401 is treated as an OAuth challenge and the plugin keeps opening the browser (with its `127.0.0.1:<port>/callback` loopback listener). The plugin also opens the browser **at most once per server per process**; later automatic attempts return the authorization-link error, and "Test connection" can still start one on demand. Upgrading migrates automatically from existing OAuth credentials, so servers that genuinely need OAuth are unaffected.
81
84
  - **Safe degradation when the hot reload does not commit**: if the native tools have not unregistered within 5 seconds of writing the disable block (that profile's patch hot reload did not commit — a sibling entry failing its re-apply rolls the whole generation back), the plugin **registers the takeover without mounting** (the managed row is marked `pendingTakeover`); the next dsh start mounts it, and "Give back" reports the same need for a restart. A mount is also always skipped when same-named `mcp__<server>__` tools already exist without a local mount, so the plugin never fights a still-mounted declaration for one name.
82
85
  - **Tool search covers them**: search/hot injection and per-tool switches work by `mcp__` prefix across the whole tool set, so declared tools need no extra configuration.
83
86
 
@@ -296,6 +299,9 @@ Check in order:
296
299
  reinstalling (link install).
297
300
  - Configuration changes (bundle additions/removals, new plugin rows) require restarting `dsh web` to enter
298
301
  the client roster.
302
+ - **Every release must declare its supported DSH version**: add a
303
+ `- **Supported DSH version**: dsh <version>` line to the CHANGELOG entry and the GitHub Release notes, and
304
+ update `package.json` → `dsh.supported` plus the "Supported DSH version" note at the top of this README.
299
305
 
300
306
  ## Changelog
301
307
 
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  [![dshfind](https://dshfind.com/api/badge/ArvinQi/dsh-mcp?lang=zh)](https://dshfind.com/zh/plugins/ArvinQi/dsh-mcp?ref=badge)
6
6
 
7
+ > **支持版本**:`dsh 0.1.5-rc.1` —— 本插件在 `0.1.5-rc.1` 上开发与验证,`package.json` → `dsh.supported` 同步声明。DSH 与插件两侧版本不匹配时,设置页会给出排查诊断(检查注册行 → 重启 `dsh web` → 硬刷新 → 同步升级)。
8
+
7
9
  ![设置页预览](static/snapshot.webp)
8
10
 
9
11
  ## 为什么用 dsh-mcp?
@@ -76,6 +78,11 @@ DSH 原生支持在组合里直接声明 MCP 服务器:**一行一台**,`nam
76
78
  `${VAR}`/裸变量名占位符——这两件事只有插件会做。因此这类声明**只能由插件挂载**:页面会把它们标为
77
79
  **挂载失败并说明原因**(不是"连接中"),「释放」会让工具消失(确认框与结果告警都会提示),恢复只需
78
80
  重新「接管」。
81
+ - **OAuth 是显式开关(1.11.1 起)**:服务器配置里的 `oauth: true`(设置页「使用 OAuth 授权」勾选,默认关)
82
+ 才会挂 OAuth provider。**用静态 token 或自定义头认证的服务器不要勾**——否则它返回 401 时会被当成 OAuth
83
+ 挑战,插件会反复打开浏览器授权(并起 `127.0.0.1:<port>/callback` 回环回调)。另外,同一服务器**每个进程
84
+ 最多自动弹一次**浏览器,之后只返回带授权链接的错误;手动「测试连接」仍可随时发起授权。升级时会按已有
85
+ OAuth 凭据自动迁移,真正需要 OAuth 的服务器不受影响。
79
86
  - **热重载未提交时的安全降级**:写入停用块后若原生工具 5 秒内没有注销(该 profile 的 patch 热重载未提交,
80
87
  例如某个兄弟条目重建失败导致整代回滚),插件会**登记接管但暂不挂载**(管理行标记 `pendingTakeover`),
81
88
  重启 dsh 后由插件挂载;「释放」在同样情况下会提示需要重启。此外,挂载前若发现同名 `mcp__<server>__`
@@ -278,5 +285,7 @@ dsh plugin --profile web add link:<本仓库绝对路径>
278
285
  - 浏览器半部改 `src/client/*` 后重新 `node scripts/build.mjs`;host 半部改动无需重装
279
286
  (link 安装直接生效)。
280
287
  - 配置变更(bundles 增删、新插件行)需重启 `dsh web` 才进入 client roster。
288
+ - **每次发版都必须声明支持的 DSH 版本**:在 CHANGELOG 对应条目与 GitHub Release notes 里加一行
289
+ `- **支持版本**:dsh <版本>`,并同步更新 `package.json` 的 `dsh.supported` 与 README 顶部的「支持版本」。
281
290
 
282
291
  [![dshfind](https://dshfind.com/api/card/ArvinQi/dsh-mcp?lang=zh)](https://dshfind.com/zh/plugins/ArvinQi/dsh-mcp?ref=badge)
package/lib/client.js CHANGED
@@ -14577,6 +14577,7 @@ var _deepseek_ai_dsh_mcp_manager_mcpManager_list_result$schema = external_export
14577
14577
  })).readonly(),
14578
14578
  "toolCallTimeoutMs": external_exports.number().readonly(),
14579
14579
  "failOnStartupError": external_exports.boolean().readonly(),
14580
+ "oauth": external_exports.boolean().readonly().optional(),
14580
14581
  "status": external_exports.object({
14581
14582
  "phase": external_exports.union([external_exports.literal("failed"), external_exports.literal("stopped"), external_exports.literal("mounting"), external_exports.literal("live")]).readonly(),
14582
14583
  "tools": external_exports.array(external_exports.string()).readonly(),
@@ -14622,7 +14623,8 @@ var _deepseek_ai_dsh_mcp_manager_mcpManager_test_parameter_0$schema = external_e
14622
14623
  "value": external_exports.string().readonly()
14623
14624
  })).readonly(),
14624
14625
  "toolCallTimeoutMs": external_exports.number().readonly(),
14625
- "failOnStartupError": external_exports.boolean().readonly()
14626
+ "failOnStartupError": external_exports.boolean().readonly(),
14627
+ "oauth": external_exports.boolean().readonly().optional()
14626
14628
  }).readonly(),
14627
14629
  "env": external_exports.array(external_exports.object({
14628
14630
  "name": external_exports.string().readonly(),
@@ -14659,7 +14661,8 @@ var _deepseek_ai_dsh_mcp_manager_mcpManager_upsert_parameter_0$schema = external
14659
14661
  "value": external_exports.string().readonly()
14660
14662
  })).readonly(),
14661
14663
  "toolCallTimeoutMs": external_exports.number().readonly(),
14662
- "failOnStartupError": external_exports.boolean().readonly()
14664
+ "failOnStartupError": external_exports.boolean().readonly(),
14665
+ "oauth": external_exports.boolean().readonly().optional()
14663
14666
  }).readonly(),
14664
14667
  "env": external_exports.array(external_exports.object({
14665
14668
  "name": external_exports.string().readonly(),
@@ -14690,6 +14693,7 @@ var _deepseek_ai_dsh_mcp_manager_mcpManager_upsert_result$schema = external_expo
14690
14693
  })).readonly(),
14691
14694
  "toolCallTimeoutMs": external_exports.number().readonly(),
14692
14695
  "failOnStartupError": external_exports.boolean().readonly(),
14696
+ "oauth": external_exports.boolean().readonly().optional(),
14693
14697
  "status": external_exports.object({
14694
14698
  "phase": external_exports.union([external_exports.literal("failed"), external_exports.literal("stopped"), external_exports.literal("mounting"), external_exports.literal("live")]).readonly(),
14695
14699
  "tools": external_exports.array(external_exports.string()).readonly(),
@@ -14731,7 +14735,8 @@ var _deepseek_ai_dsh_mcp_manager_mcpManager_upsertJson_parameter_0$schema = exte
14731
14735
  "value": external_exports.string().readonly()
14732
14736
  })).readonly()]).readonly().optional(),
14733
14737
  "toolCallTimeoutMs": external_exports.number().readonly().optional(),
14734
- "failOnStartupError": external_exports.boolean().readonly().optional()
14738
+ "failOnStartupError": external_exports.boolean().readonly().optional(),
14739
+ "oauth": external_exports.boolean().readonly().optional()
14735
14740
  }).readonly(),
14736
14741
  "env": external_exports.array(external_exports.object({
14737
14742
  "name": external_exports.string().readonly(),
@@ -14767,6 +14772,7 @@ var _deepseek_ai_dsh_mcp_manager_mcpManager_upsertJson_result$schema = external_
14767
14772
  })).readonly(),
14768
14773
  "toolCallTimeoutMs": external_exports.number().readonly(),
14769
14774
  "failOnStartupError": external_exports.boolean().readonly(),
14775
+ "oauth": external_exports.boolean().readonly().optional(),
14770
14776
  "status": external_exports.object({
14771
14777
  "phase": external_exports.union([external_exports.literal("failed"), external_exports.literal("stopped"), external_exports.literal("mounting"), external_exports.literal("live")]).readonly(),
14772
14778
  "tools": external_exports.array(external_exports.string()).readonly(),
@@ -14887,6 +14893,7 @@ var _deepseek_ai_dsh_mcp_manager_mcpManager_adopt_result$schema = external_expor
14887
14893
  })).readonly(),
14888
14894
  "toolCallTimeoutMs": external_exports.number().readonly(),
14889
14895
  "failOnStartupError": external_exports.boolean().readonly(),
14896
+ "oauth": external_exports.boolean().readonly().optional(),
14890
14897
  "status": external_exports.object({
14891
14898
  "phase": external_exports.union([external_exports.literal("failed"), external_exports.literal("stopped"), external_exports.literal("mounting"), external_exports.literal("live")]).readonly(),
14892
14899
  "tools": external_exports.array(external_exports.string()).readonly(),
@@ -15225,7 +15232,10 @@ function emptyDraft() {
15225
15232
  toolCallTimeoutMs: String(DEFAULT_TOOL_CALL_TIMEOUT_MS),
15226
15233
  // Reject the mount by default when the startup connection fails, so a
15227
15234
  // broken server never registers stale tools.
15228
- failOnStartupError: true
15235
+ failOnStartupError: true,
15236
+ // OAuth is opt-in: guessing it from the headers opened the browser for
15237
+ // servers that authenticate with static tokens.
15238
+ oauth: false
15229
15239
  };
15230
15240
  }
15231
15241
  function draftFromServer(server) {
@@ -15240,6 +15250,7 @@ function draftFromServer(server) {
15240
15250
  url: server.url,
15241
15251
  headersText: server.headers.map((header) => `${header.name}: ${header.value}`).join("\n"),
15242
15252
  toolCallTimeoutMs: String(server.toolCallTimeoutMs),
15253
+ oauth: server.oauth === true,
15243
15254
  failOnStartupError: server.failOnStartupError
15244
15255
  };
15245
15256
  }
@@ -15326,6 +15337,7 @@ function draftToSubmission(draft) {
15326
15337
  url: draft.url.trim(),
15327
15338
  headers: parseHeaders(draft.headersText),
15328
15339
  toolCallTimeoutMs: parseTimeout(draft.toolCallTimeoutMs),
15340
+ oauth: draft.oauth,
15329
15341
  failOnStartupError: draft.failOnStartupError
15330
15342
  }
15331
15343
  };
@@ -15591,6 +15603,18 @@ function McpServerForm(props) {
15591
15603
  }
15592
15604
  )
15593
15605
  ] }),
15606
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: ServerForm_default.checkRow, children: [
15607
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
15608
+ "input",
15609
+ {
15610
+ type: "checkbox",
15611
+ checked: draft.oauth,
15612
+ onChange: (event) => update({ oauth: event.currentTarget.checked })
15613
+ }
15614
+ ),
15615
+ t("oauthAuth")
15616
+ ] }),
15617
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: ServerForm_default.hint, children: t("oauthAuthHint") }),
15594
15618
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: ServerForm_default.checkRow, children: [
15595
15619
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
15596
15620
  "input",
@@ -15663,6 +15687,7 @@ function serversToJsonText(servers) {
15663
15687
  }
15664
15688
  entry.toolCallTimeoutMs = server.toolCallTimeoutMs;
15665
15689
  entry.failOnStartupError = server.failOnStartupError;
15690
+ if (server.oauth === true) entry.oauth = true;
15666
15691
  entry.env = server.env.map((row) => row.secret ? { name: row.name, secret: true, configured: row.configured } : { name: row.name, secret: false, ...row.value !== void 0 && row.value.length > 0 ? { value: row.value } : {} });
15667
15692
  document2[server.serverName] = entry;
15668
15693
  }
@@ -15730,6 +15755,7 @@ function parseServersJson(text) {
15730
15755
  url: typeof cfg.url === "string" ? cfg.url : "",
15731
15756
  headers: cfg.headers,
15732
15757
  toolCallTimeoutMs: typeof cfg.toolCallTimeoutMs === "number" ? cfg.toolCallTimeoutMs : 6e4,
15758
+ oauth: cfg.oauth === true,
15733
15759
  failOnStartupError: cfg.failOnStartupError !== false
15734
15760
  };
15735
15761
  servers.push({ server, env });
@@ -16093,6 +16119,7 @@ function viewToDraft(server) {
16093
16119
  url: server.url,
16094
16120
  headersText: server.headers.map((header) => `${header.name}: ${header.value}`).join("\n"),
16095
16121
  toolCallTimeoutMs: String(server.toolCallTimeoutMs),
16122
+ oauth: server.oauth === true,
16096
16123
  failOnStartupError: server.failOnStartupError
16097
16124
  };
16098
16125
  }
@@ -16515,6 +16542,8 @@ var zh = {
16515
16542
  removeEnvVar: "\u79FB\u9664\u8BE5\u53D8\u91CF",
16516
16543
  toolCallTimeoutMs: "\u5DE5\u5177\u8C03\u7528\u8D85\u65F6\uFF08\u6BEB\u79D2\uFF09",
16517
16544
  failOnStartupError: "\u542F\u52A8\u8FDE\u63A5\u5931\u8D25\u65F6\u62D2\u7EDD\u6302\u8F7D",
16545
+ oauthAuth: "\u4F7F\u7528 OAuth \u6388\u6743\uFF08\u6D4F\u89C8\u5668\u6388\u6743 + token \u6258\u7BA1\uFF09",
16546
+ oauthAuthHint: "\u4EC5\u5F53\u8BE5\u670D\u52A1\u5668\u786E\u5B9E\u4F7F\u7528 OAuth\uFF08MCP \u6388\u6743\u7801 + PKCE\uFF09\u65F6\u52FE\u9009\u3002\u7528\u9759\u6001 token \u6216\u81EA\u5B9A\u4E49\u8BF7\u6C42\u5934\u8BA4\u8BC1\u7684\u670D\u52A1\u5668\u8BF7\u52FF\u52FE\u9009\u2014\u2014\u5426\u5219\u670D\u52A1\u5668\u8FD4\u56DE 401 \u65F6\u4F1A\u88AB\u5F53\u6210 OAuth \u6311\u6218\uFF0C\u4ECE\u800C\u53CD\u590D\u5F39\u51FA\u6D4F\u89C8\u5668\u6388\u6743\u3002",
16518
16547
  statusMounting: "\u8FDE\u63A5\u4E2D",
16519
16548
  statusLive: "\u5DF2\u8FDE\u63A5",
16520
16549
  statusFailed: "\u6302\u8F7D\u5931\u8D25",
@@ -16638,6 +16667,8 @@ var en = {
16638
16667
  removeEnvVar: "Remove this variable",
16639
16668
  toolCallTimeoutMs: "Tool-call timeout (ms)",
16640
16669
  failOnStartupError: "Reject the mount when the startup connection fails",
16670
+ oauthAuth: "Use OAuth authorization (browser authorization + token storage)",
16671
+ oauthAuthHint: "Tick only when the server really uses OAuth (MCP authorization-code + PKCE). Servers that authenticate with static tokens or custom headers must leave this off \u2014 otherwise a 401 is treated as an OAuth challenge and the browser authorization opens again and again.",
16641
16672
  statusMounting: "Connecting",
16642
16673
  statusLive: "Connected",
16643
16674
  statusFailed: "Mount failed",
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { Service } from "@deepseek-ai/cordis";
3
3
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
4
4
  import * as mcpClient from "./mcp-client.js";
5
- import { createOAuthProvider } from "./oauth.js";
5
+ import { createOAuthProvider, oauthCredentialRefs } from "./oauth.js";
6
6
  import { probeConnection } from "./probe.js";
7
7
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
8
8
  import { hostText } from "./host-locales.js";
@@ -56,6 +56,7 @@ const mcpServerRowSchema = z$1.object({
56
56
  env: z$1.array(envEntrySchema).default([]),
57
57
  toolCallTimeoutMs: positiveIntegerSchema,
58
58
  failOnStartupError: z$1.boolean().default(false),
59
+ oauth: z$1.boolean().default(false),
59
60
  origin: z$1.union([z$1.literal("plugin"), z$1.literal("cordis")]).default("plugin"),
60
61
  declaredIn: z$1.string().optional(),
61
62
  declaredRowId: z$1.string().optional(),
@@ -184,6 +185,9 @@ function toServerRow(id, server, env, previous) {
184
185
  }),
185
186
  toolCallTimeoutMs: server.toolCallTimeoutMs,
186
187
  failOnStartupError: server.failOnStartupError,
188
+ // The OAuth switch is explicit: a caller that states nothing (the JSON
189
+ // editor omitting the field) keeps the previous choice.
190
+ oauth: server.oauth === true ? true : server.oauth === false ? false : previous?.oauth === true,
187
191
  // Rows created here are the manager's own; mirrors imported from a
188
192
  // Cordis patch layer are written by syncDeclared with origin "cordis".
189
193
  origin: "plugin",
@@ -327,7 +331,7 @@ function placeholderName(value, envNames) {
327
331
  }
328
332
  /** Whether two server records carry the same content (mirror refresh guard). */
329
333
  function sameRecord(left, right) {
330
- return left.serverName === right.serverName && left.transport === right.transport && left.enabled === right.enabled && left.command === right.command && left.cwd === right.cwd && left.url === right.url && left.toolCallTimeoutMs === right.toolCallTimeoutMs && left.failOnStartupError === right.failOnStartupError && left.declaredIn === right.declaredIn && left.declaredRowId === right.declaredRowId && left.stale === right.stale && JSON.stringify(left.args) === JSON.stringify(right.args) && JSON.stringify(left.headers) === JSON.stringify(right.headers) && JSON.stringify(left.env) === JSON.stringify(right.env);
334
+ return left.serverName === right.serverName && left.transport === right.transport && left.enabled === right.enabled && left.command === right.command && left.cwd === right.cwd && left.url === right.url && left.toolCallTimeoutMs === right.toolCallTimeoutMs && left.failOnStartupError === right.failOnStartupError && left.oauth === right.oauth && left.declaredIn === right.declaredIn && left.declaredRowId === right.declaredRowId && left.stale === right.stale && JSON.stringify(left.args) === JSON.stringify(right.args) && JSON.stringify(left.headers) === JSON.stringify(right.headers) && JSON.stringify(left.env) === JSON.stringify(right.env);
331
335
  }
332
336
  /**
333
337
  * The managed server id a credential reference belongs to, when the reference
@@ -561,6 +565,11 @@ let McpManagerService = (() => {
561
565
  * disabled and re-enabled.
562
566
  */
563
567
  everMounted = /* @__PURE__ */ new Set();
568
+ /**
569
+ * Server names whose one automatic browser authorization this process
570
+ * already consumed (see {@link claimAutoBrowser}).
571
+ */
572
+ autoBrowserOpened = /* @__PURE__ */ new Set();
564
573
  /** Per-id operation chain serializing reconcile, remove, and restart jobs. */
565
574
  operationTails = /* @__PURE__ */ new Map();
566
575
  /** Server ids whose secret writes are this manager's own; skip restart echoes. */
@@ -594,6 +603,7 @@ let McpManagerService = (() => {
594
603
  }, "mcp-manager.domainClose");
595
604
  this.table = domain.table("servers");
596
605
  this.envTable = domain.table("global_env");
606
+ await this.migrateOAuthFlags();
597
607
  await this.refreshDeclared(false);
598
608
  this.reconcileDeclared();
599
609
  this.installToolControl();
@@ -808,7 +818,11 @@ let McpManagerService = (() => {
808
818
  ...toMirrorRecord(entry),
809
819
  origin: "plugin",
810
820
  enabled: true,
811
- stale: false
821
+ stale: false,
822
+ // A declaration with no `Authorization` header is the shape an
823
+ // OAuth-protected server has; default the switch on for the
824
+ // takeover (it stays visible and editable in the form).
825
+ oauth: entry.transport === "streamable-http" && !entry.headers.some((header) => header.name.toLowerCase() === "authorization")
812
826
  });
813
827
  if (!parsed.success) return this.adoptFailure("MCP_ADOPT_FAILED", parsed.error.issues[0]?.message ?? "invalid declaration");
814
828
  const disable = writeManagedIds(entry.declaredIn, [...managedIds(entry.declaredIn), entry.rowId]);
@@ -1036,19 +1050,64 @@ let McpManagerService = (() => {
1036
1050
  };
1037
1051
  }
1038
1052
  /**
1039
- * Whether one resolved client config needs an OAuth provider. Only
1040
- * streamable-http servers WITHOUT a static `Authorization` header do:
1041
- * a static bearer token means the server authenticates directly, and
1042
- * attaching an OAuth provider would make the SDK treat any 401 as an
1043
- * authorization-code challenge opening the browser / blocking tool
1044
- * calls for servers that never intended OAuth. stdio never needs it.
1045
- * @param config - the resolved client config from {@link toClientConfig}.
1053
+ * Whether one stored server asks for an OAuth provider. The switch is
1054
+ * explicit (`oauth: true`, set in the settings form): a server that
1055
+ * authenticates with static headers must never be guessed into OAuth,
1056
+ * because a 401 from it is not a challenge and starting the
1057
+ * authorization-code flow for it only opens the browser forever.
1058
+ * stdio never needs the provider.
1059
+ * @param row - the stored definition (or the submitted spec).
1046
1060
  * @returns true when OAuth (authorization-code + PKCE) should be enabled.
1047
1061
  */
1048
- wantsOAuthProvider(config) {
1049
- if (config.transport !== "streamable-http") return false;
1050
- const names = Object.keys(config.headers ?? {});
1051
- return !names.some((name) => name.toLowerCase() === "authorization");
1062
+ wantsOAuthProvider(row) {
1063
+ return row?.transport === "streamable-http" && row.oauth === true;
1064
+ }
1065
+ /**
1066
+ * Claim the one automatic browser authorization this process allows per
1067
+ * server. Without it a server whose 401 can never be satisfied by the
1068
+ * authorization-code flow (or whose token keeps being rejected) reopens
1069
+ * the browser on every mount and reconnect; later attempts surface the
1070
+ * authorization link instead, and `test` still opens the browser because
1071
+ * the user asked for it.
1072
+ * @param serverName - the server the provider belongs to.
1073
+ * @returns true when this process may open the browser automatically.
1074
+ */
1075
+ claimAutoBrowser(serverName) {
1076
+ if (this.autoBrowserOpened.has(serverName)) return false;
1077
+ this.autoBrowserOpened.add(serverName);
1078
+ return true;
1079
+ }
1080
+ /**
1081
+ * Give stored rows that completed an OAuth flow before the explicit
1082
+ * switch existed their provider back (`oauth: true`). The switch replaced
1083
+ * a header heuristic, so a row with stored OAuth credentials really does
1084
+ * need authorization; rows without them stay off and stop opening the
1085
+ * browser for a flow that can never succeed.
1086
+ */
1087
+ async migrateOAuthFlags() {
1088
+ const table = this.requireTable();
1089
+ for (const [id, row] of table.entries()) {
1090
+ if (row.oauth === true || row.origin === "cordis") continue;
1091
+ let configured = false;
1092
+ for (const ref of oauthCredentialRefs(row.serverName)) {
1093
+ try {
1094
+ if ((await this.ctx.credentials.describe(ref)).configured) {
1095
+ configured = true;
1096
+ break;
1097
+ }
1098
+ } catch {
1099
+ /* an absent credential document is not an error */
1100
+ }
1101
+ }
1102
+ if (!configured) continue;
1103
+ const parsed = mcpServerRowSchema.safeParse({
1104
+ ...row,
1105
+ oauth: true
1106
+ });
1107
+ if (!parsed.success) continue;
1108
+ await table.put(id, parsed.data);
1109
+ this.ctx.logger.info(`mcp-manager: "${row.serverName}" keeps OAuth (stored credentials) after the explicit switch`);
1110
+ }
1052
1111
  }
1053
1112
  /**
1054
1113
  * Mount one server's mcp-client instance without awaiting activation, so a
@@ -1071,15 +1130,17 @@ let McpManagerService = (() => {
1071
1130
  const env = await this.resolveEnv(row);
1072
1131
  const globalEnv = await this.resolveGlobalEnv();
1073
1132
  const config = this.toClientConfig(row, env, globalEnv);
1074
- // OAuth servers (authorization-code flow) trigger browser
1075
- // auth on connect (unless allowBrowserOnMount is off, the
1076
- // default); static-credential servers get no provider so
1077
- // their 401s surface as authentication failures instead of
1078
- // an OAuth flow the server never asked for. Tokens are
1079
- // keyed by serverName so a test-connection authorization
1080
- // is reused by the mount.
1081
- const authProvider = this.wantsOAuthProvider(config)
1082
- ? createOAuthProvider(row.serverName, this.ctx, { allowBrowser: this.allowBrowserOnMount, serverUrl: row.url })
1133
+ // The OAuth switch is explicit per server (`oauth: true`), and
1134
+ // automatic browser authorization happens at most once per
1135
+ // server per process: later attempts surface the
1136
+ // authorization link instead of opening the browser again.
1137
+ // Tokens are keyed by serverName, so a test-connection
1138
+ // authorization is reused by the mount.
1139
+ const authProvider = this.wantsOAuthProvider(row)
1140
+ ? createOAuthProvider(row.serverName, this.ctx, {
1141
+ allowBrowser: this.allowBrowserOnMount && this.claimAutoBrowser(row.serverName),
1142
+ serverUrl: row.url
1143
+ })
1083
1144
  : void 0;
1084
1145
  handle = this.ctx.plugin(MCP_CLIENT_PLUGIN, { ...config, ...authProvider === void 0 ? {} : { authProvider } });
1085
1146
  } catch (error) {
@@ -1182,6 +1243,7 @@ let McpManagerService = (() => {
1182
1243
  env,
1183
1244
  toolCallTimeoutMs: row.toolCallTimeoutMs,
1184
1245
  failOnStartupError: row.failOnStartupError,
1246
+ ...(mirror ? {} : { oauth: row.oauth === true }),
1185
1247
  ...(mirrorNeedsPlugin ? { needsPlugin: true } : {}),
1186
1248
  status: {
1187
1249
  // A mirror never mounts here: its phase follows the declaration
@@ -1393,7 +1455,8 @@ let McpManagerService = (() => {
1393
1455
  url: typeof item.server.url === "string" ? item.server.url : "",
1394
1456
  headers: normalizeHeaders(item.server.headers),
1395
1457
  toolCallTimeoutMs: Number.isInteger(item.server.toolCallTimeoutMs) ? item.server.toolCallTimeoutMs : 60e3,
1396
- failOnStartupError: item.server.failOnStartupError !== false
1458
+ failOnStartupError: item.server.failOnStartupError !== false,
1459
+ oauth: item.server.oauth === void 0 ? void 0 : item.server.oauth === true
1397
1460
  };
1398
1461
  try {
1399
1462
  validateServerInput(spec, env);
@@ -1595,12 +1658,13 @@ let McpManagerService = (() => {
1595
1658
  // Test connection also supports OAuth: an OAuth-protected server
1596
1659
  // opens the browser for authorization and the probe continues after
1597
1660
  // tokens are stored (keyed by serverName, matching the mount path).
1598
- // Static-credential servers (configured Authorization header) skip
1599
- // the provider so a 401 probe reports the authentication failure
1600
- // instead of starting an OAuth flow the server never asked for.
1661
+ // The user asked for this probe, so it may open the browser even
1662
+ // when the mount-time circuit breaker already fired; servers without
1663
+ // the explicit switch skip the provider so a 401 reports an
1664
+ // authentication failure instead of starting a bogus flow.
1601
1665
  const probe = await probeConnection(probeConfig, {
1602
1666
  timeoutMs: this.probeTimeoutMs,
1603
- ...(this.wantsOAuthProvider(probeConfig)
1667
+ ...(this.wantsOAuthProvider(request.server)
1604
1668
  ? { authProvider: createOAuthProvider(request.server.serverName, this.ctx, { serverUrl: request.server.url }) }
1605
1669
  : {})
1606
1670
  });
package/lib/oauth.js CHANGED
@@ -77,6 +77,19 @@ function oauthClientRef(serverId) {
77
77
  return oauthRef(OAUTH_CLIENT_REF_PREFIX, serverId);
78
78
  }
79
79
 
80
+ /**
81
+ * The credential references one server's OAuth flow writes (token + client).
82
+ * Recognising servers that demonstrably completed a flow is how stored rows
83
+ * migrate to the explicit OAuth switch: the switch replaced a header
84
+ * heuristic, and a server with stored OAuth credentials really does need
85
+ * authorization.
86
+ * @param serverId - the server name the flow is keyed by.
87
+ * @returns both credential reference names.
88
+ */
89
+ export function oauthCredentialRefs(serverId) {
90
+ return [oauthTokenRef(serverId), oauthClientRef(serverId)];
91
+ }
92
+
80
93
  /** Open the system browser to one URL, best effort. */
81
94
  function openBrowser(url) {
82
95
  const command =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "MCP 管理界面 + tool search:可视化配置/启停/刷新 MCP 服务器与工具勾选,按需检索(tool search)热注入;工具列表稳定命中缓存、不撑爆上下文。DeepSeek Harness plugin: MCP management UI + tool search — stable tool list, cache-friendly, no context bloat.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -35,6 +35,7 @@
35
35
  "url": "https://github.com/ArvinQi/dsh-mcp/issues"
36
36
  },
37
37
  "dsh": {
38
+ "supported": "0.1.5-rc.1",
38
39
  "client": {
39
40
  "inject": [
40
41
  "@deepseek-ai/dsh-api-remotes",
@@ -73,6 +73,7 @@ function viewToDraft(server: McpServerView): McpDraft {
73
73
  url: server.url,
74
74
  headersText: server.headers.map(header => `${header.name}: ${header.value}`).join('\n'),
75
75
  toolCallTimeoutMs: String(server.toolCallTimeoutMs),
76
+ oauth: server.oauth === true,
76
77
  failOnStartupError: server.failOnStartupError,
77
78
  }
78
79
  }
@@ -225,6 +225,16 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
225
225
  />
226
226
  </label>
227
227
 
228
+ <label className={css.checkRow}>
229
+ <input
230
+ type="checkbox"
231
+ checked={draft.oauth}
232
+ onChange={(event) => update({ oauth: event.currentTarget.checked })}
233
+ />
234
+ {t('oauthAuth')}
235
+ </label>
236
+ <p className={css.hint}>{t('oauthAuthHint')}</p>
237
+
228
238
  <label className={css.checkRow}>
229
239
  <input
230
240
  type="checkbox"
@@ -77,6 +77,7 @@ export function serversToJsonText(servers: readonly McpServerView[]): string {
77
77
  }
78
78
  entry.toolCallTimeoutMs = server.toolCallTimeoutMs
79
79
  entry.failOnStartupError = server.failOnStartupError
80
+ if (server.oauth === true) entry.oauth = true
80
81
  entry.env = server.env.map(row => row.secret
81
82
  ? { name: row.name, secret: true, configured: row.configured }
82
83
  : { name: row.name, secret: false, ...(row.value !== undefined && row.value.length > 0 ? { value: row.value } : {}) })
@@ -150,6 +151,7 @@ export function parseServersJson(text: string): { ok: true; servers: readonly Se
150
151
  url: typeof cfg.url === 'string' ? cfg.url : '',
151
152
  headers: cfg.headers,
152
153
  toolCallTimeoutMs: typeof cfg.toolCallTimeoutMs === 'number' ? cfg.toolCallTimeoutMs : 60000,
154
+ oauth: cfg.oauth === true,
153
155
  failOnStartupError: cfg.failOnStartupError !== false,
154
156
  }
155
157
  servers.push({ server, env })
@@ -39,6 +39,8 @@ export const zh = {
39
39
  removeEnvVar: '移除该变量',
40
40
  toolCallTimeoutMs: '工具调用超时(毫秒)',
41
41
  failOnStartupError: '启动连接失败时拒绝挂载',
42
+ oauthAuth: '使用 OAuth 授权(浏览器授权 + token 托管)',
43
+ oauthAuthHint: '仅当该服务器确实使用 OAuth(MCP 授权码 + PKCE)时勾选。用静态 token 或自定义请求头认证的服务器请勿勾选——否则服务器返回 401 时会被当成 OAuth 挑战,从而反复弹出浏览器授权。',
42
44
  statusMounting: '连接中',
43
45
  statusLive: '已连接',
44
46
  statusFailed: '挂载失败',
@@ -167,6 +169,8 @@ export const en = {
167
169
  removeEnvVar: 'Remove this variable',
168
170
  toolCallTimeoutMs: 'Tool-call timeout (ms)',
169
171
  failOnStartupError: 'Reject the mount when the startup connection fails',
172
+ oauthAuth: 'Use OAuth authorization (browser authorization + token storage)',
173
+ oauthAuthHint: 'Tick only when the server really uses OAuth (MCP authorization-code + PKCE). Servers that authenticate with static tokens or custom headers must leave this off — otherwise a 401 is treated as an OAuth challenge and the browser authorization opens again and again.',
170
174
  statusMounting: 'Connecting',
171
175
  statusLive: 'Connected',
172
176
  statusFailed: 'Mount failed',
@@ -25,6 +25,8 @@ export interface McpDraft {
25
25
  readonly headersText: string
26
26
  /** Timeout as editor text; blank falls back to the default. */
27
27
  readonly toolCallTimeoutMs: string
28
+ /** Explicit OAuth switch: only then does the Host attach an OAuth provider. */
29
+ readonly oauth: boolean
28
30
  readonly failOnStartupError: boolean
29
31
  }
30
32
 
@@ -78,6 +80,9 @@ export function emptyDraft(): McpDraft {
78
80
  // Reject the mount by default when the startup connection fails, so a
79
81
  // broken server never registers stale tools.
80
82
  failOnStartupError: true,
83
+ // OAuth is opt-in: guessing it from the headers opened the browser for
84
+ // servers that authenticate with static tokens.
85
+ oauth: false,
81
86
  }
82
87
  }
83
88
 
@@ -94,6 +99,7 @@ export function draftFromServer(server: McpServerView): McpDraft {
94
99
  url: server.url,
95
100
  headersText: server.headers.map(header => `${header.name}: ${header.value}`).join('\n'),
96
101
  toolCallTimeoutMs: String(server.toolCallTimeoutMs),
102
+ oauth: server.oauth === true,
97
103
  failOnStartupError: server.failOnStartupError,
98
104
  }
99
105
  }
@@ -205,6 +211,7 @@ export function draftToSubmission(draft: McpDraft): McpSubmission {
205
211
  url: draft.url.trim(),
206
212
  headers: parseHeaders(draft.headersText),
207
213
  toolCallTimeoutMs: parseTimeout(draft.toolCallTimeoutMs),
214
+ oauth: draft.oauth,
208
215
  failOnStartupError: draft.failOnStartupError,
209
216
  },
210
217
  }
@@ -36,6 +36,7 @@ const _deepseek_ai_dsh_mcp_manager_mcpManager_list_result$schema = z.object({
36
36
  })).readonly(),
37
37
  'toolCallTimeoutMs': z.number().readonly(),
38
38
  'failOnStartupError': z.boolean().readonly(),
39
+ 'oauth': z.boolean().readonly().optional(),
39
40
  'status': z.object({
40
41
  'phase': z.union([z.literal("failed"), z.literal("stopped"), z.literal("mounting"), z.literal("live")]).readonly(),
41
42
  'tools': z.array(z.string()).readonly(),
@@ -82,6 +83,7 @@ const _deepseek_ai_dsh_mcp_manager_mcpManager_test_parameter_0$schema = z.object
82
83
  })).readonly(),
83
84
  'toolCallTimeoutMs': z.number().readonly(),
84
85
  'failOnStartupError': z.boolean().readonly(),
86
+ 'oauth': z.boolean().readonly().optional(),
85
87
  }).readonly(),
86
88
  'env': z.array(z.object({
87
89
  'name': z.string().readonly(),
@@ -119,6 +121,7 @@ const _deepseek_ai_dsh_mcp_manager_mcpManager_upsert_parameter_0$schema = z.obje
119
121
  })).readonly(),
120
122
  'toolCallTimeoutMs': z.number().readonly(),
121
123
  'failOnStartupError': z.boolean().readonly(),
124
+ 'oauth': z.boolean().readonly().optional(),
122
125
  }).readonly(),
123
126
  'env': z.array(z.object({
124
127
  'name': z.string().readonly(),
@@ -149,6 +152,7 @@ const _deepseek_ai_dsh_mcp_manager_mcpManager_upsert_result$schema = z.union([z.
149
152
  })).readonly(),
150
153
  'toolCallTimeoutMs': z.number().readonly(),
151
154
  'failOnStartupError': z.boolean().readonly(),
155
+ 'oauth': z.boolean().readonly().optional(),
152
156
  'status': z.object({
153
157
  'phase': z.union([z.literal("failed"), z.literal("stopped"), z.literal("mounting"), z.literal("live")]).readonly(),
154
158
  'tools': z.array(z.string()).readonly(),
@@ -191,6 +195,7 @@ const _deepseek_ai_dsh_mcp_manager_mcpManager_upsertJson_parameter_0$schema = z.
191
195
  })).readonly()]).readonly().optional(),
192
196
  'toolCallTimeoutMs': z.number().readonly().optional(),
193
197
  'failOnStartupError': z.boolean().readonly().optional(),
198
+ 'oauth': z.boolean().readonly().optional(),
194
199
  }).readonly(),
195
200
  'env': z.array(z.object({
196
201
  'name': z.string().readonly(),
@@ -226,6 +231,7 @@ const _deepseek_ai_dsh_mcp_manager_mcpManager_upsertJson_result$schema = z.union
226
231
  })).readonly(),
227
232
  'toolCallTimeoutMs': z.number().readonly(),
228
233
  'failOnStartupError': z.boolean().readonly(),
234
+ 'oauth': z.boolean().readonly().optional(),
229
235
  'status': z.object({
230
236
  'phase': z.union([z.literal("failed"), z.literal("stopped"), z.literal("mounting"), z.literal("live")]).readonly(),
231
237
  'tools': z.array(z.string()).readonly(),
@@ -347,6 +353,7 @@ const _deepseek_ai_dsh_mcp_manager_mcpManager_adopt_result$schema = z.union([z.o
347
353
  })).readonly(),
348
354
  'toolCallTimeoutMs': z.number().readonly(),
349
355
  'failOnStartupError': z.boolean().readonly(),
356
+ 'oauth': z.boolean().readonly().optional(),
350
357
  'status': z.object({
351
358
  'phase': z.union([z.literal("failed"), z.literal("stopped"), z.literal("mounting"), z.literal("live")]).readonly(),
352
359
  'tools': z.array(z.string()).readonly(),
@@ -44,6 +44,8 @@ export interface McpServerInput {
44
44
  readonly url: string
45
45
  readonly headers: readonly McpHeaderEntry[]
46
46
  readonly toolCallTimeoutMs: number
47
+ /** Explicit OAuth switch: only then does the Host attach an OAuth provider. */
48
+ readonly oauth: boolean
47
49
  readonly failOnStartupError: boolean
48
50
  }
49
51
 
@@ -86,6 +88,8 @@ export interface McpServerView {
86
88
  readonly env: readonly McpEnvVarView[]
87
89
  readonly toolCallTimeoutMs: number
88
90
  readonly failOnStartupError: boolean
91
+ /** Explicit OAuth switch of a managed row (absent for declared rows). */
92
+ readonly oauth?: boolean
89
93
  readonly status: McpServerStatus
90
94
  /** `plugin` = stored and mounted here; `cordis` = declared in a patch layer. */
91
95
  readonly source?: McpServerSource