dsh-mcp 1.1.0 → 1.2.0

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,24 @@ 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
+ ## [1.2.0] - 2026-08-16
11
+
12
+ ### Added
13
+
14
+ - **OAuth authentication**: `streamable-http` servers using MCP OAuth (authorization-code + PKCE) trigger browser authorization on connect; tokens are persisted (credentials document) and refreshed automatically by the SDK (auto-renewed while active within 24h) (`lib/oauth.js`)
15
+
16
+ ### Fixed
17
+
18
+ - OAuth token credential-ref names collided with hyphens in server ids and failed credential validation (ref names only allow `[A-Za-z_][A-Za-z0-9_]*`): refs now use a sanitized server id plus a stable short hash, avoiding illegal characters and naming collisions
19
+ - The interactive OAuth probe budget was raised from 90 seconds to 5 minutes: the first authorization requires browser login/approval, and slower-than-90s flows caused the probe to time out and report a false failure (the authorization had actually succeeded and tokens were saved); now the test result appears automatically once authorization completes
20
+ - Disabled servers no longer render two "Disabled" badges (the phase badge plus a redundant caption)
21
+ - The settings page primary buttons (Add/Save) and the "Connecting" badge used theme tokens that do not exist in the web shell, breaking their text color: switched to the shell's real theme tokens (`--dsw-alias-button-primary-fill` / `--dsw-alias-label-primary-foreground` / `--dsw-alias-brand-primary`)
22
+
23
+ ### Improved
24
+
25
+ - While testing a streamable-http server, a hint explains that a browser authorization page may open and the result refreshes automatically after it is completed
26
+ - After saving a server, the list refreshes itself on a delay so "Connecting" settles to "Connected" once the mount is live
27
+
10
28
  ## [1.1.0] - 2026-08-15
11
29
 
12
30
  ### Added
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@
7
7
  格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
8
8
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
9
9
 
10
+ ## [1.2.0] - 2026-08-16
11
+
12
+ ### 新增
13
+
14
+ - **OAuth 认证支持**:`streamable-http` 服务器支持 MCP OAuth(授权码 + PKCE),连接时自动打开浏览器授权;token 持久化(凭据文档)并由 SDK 自动刷新(24 小时内活跃自动续期)(`lib/oauth.js`)
15
+
16
+ ### 修复
17
+
18
+ - OAuth token 凭据引用名与服务器名中的连字符冲突导致 `resolve` 校验失败(凭据引用名仅允许 `[A-Za-z_][A-Za-z0-9_]*`):引用名改为清洗后的服务器名 + 稳定哈希,避免非法字符与命名碰撞
19
+ - OAuth 交互授权探测预算从 90 秒提升到 5 分钟:首次授权需在浏览器完成登录/同意,慢于 90 秒会导致探测提前超时并误报连接失败(授权其实已成功、token 已保存),现授权完成后测试结果会自动展示
20
+ - 禁用状态的服务器不再重复展示两个「未启用」徽标(badge 与补充文案叠加)
21
+ - 设置页 primary 按钮(添加/保存)与「连接中」徽标使用了不存在的主题 token,导致文字颜色异常:改用 web shell 真实主题 token(`--dsw-alias-button-primary-fill` / `--dsw-alias-label-primary-foreground` / `--dsw-alias-brand-primary`)
22
+
23
+ ### 优化
24
+
25
+ - 测试连接(streamable-http)期间提示浏览器授权:若弹出授权页,完成授权后返回,结果自动刷新
26
+ - 保存服务器后自动延迟刷新列表状态,挂载完成后「连接中」自动变为「已连接」
27
+
10
28
  ## [1.1.0] - 2026-08-15
11
29
 
12
30
  ### 新增
package/README.en.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-mcp — MCP server management plugin for DeepSeek Harness (standalone)
1
+ # dsh-mcp — MCP management UI + tool search: stable tool list, cache hits, no context bloat
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
 
@@ -18,6 +18,7 @@
18
18
  - **Fine-grained tool control**: expand each server to see its tools, all checked by default; uncheck to load only what you need
19
19
  - **Two injection modes**: `search` (on-demand, token-saving) and `full` (inject everything)
20
20
  - **Zero npm dependencies**: plugs into DeepSeek Harness internals, install and go
21
+ - **OAuth authentication**: for `streamable-http` servers using MCP OAuth (authorization-code + PKCE), the browser opens automatically for authorization on connect; tokens are persisted and refreshed automatically by the SDK (auto-renewed while active within 24h)
21
22
  - **Three install paths**: npm / GitHub git source / local link; bilingual UI and docs
22
23
 
23
24
  Migrated and merged from uncommitted MCP work in the `deepseek-harness` repository:
@@ -40,6 +41,10 @@ Migrated and merged from uncommitted MCP work in the `deepseek-harness` reposito
40
41
  - **Tool control**:
41
42
  - Injection mode: `search` (on-demand, default — the model hot-injects tools via `mcp_tool_search`) and `full` (inject every enabled tool each request).
42
43
  - Expandable per-server tool list, all checked by default; unchecking a tool keeps it out of injection. Changes take effect immediately.
44
+ - **OAuth authentication** (host, `lib/oauth.js`): on a 401 + OAuth challenge from a `streamable-http`
45
+ server, runs the authorization-code + PKCE flow automatically — opens the browser, receives the
46
+ callback on a loopback server, persists tokens, and refreshes them on demand; test connection and
47
+ mounts share the same token.
43
48
  - **Remote self-mount**: the client half mounts the `mcpManager` Remote namespace itself via `ctx.remote.$mount()` in `apply()`,
44
49
  so no in-box package modification is required.
45
50
  - Zero npm runtime dependencies (`@deepseek-ai/*` resolve from the DSH profiles module fallback).
@@ -52,6 +57,7 @@ dsh-mcp/
52
57
  ├── lib/
53
58
  │ ├── index.js host half (McpManagerService, built from mcp-manager)
54
59
  │ ├── mcp-client.js vendored MCP client (from @deepseek-ai/dsh-mcp-client, with tool-list stability extension)
60
+ │ ├── oauth.js MCP OAuth client provider (authorization-code + PKCE, loopback callback, token persistence)
55
61
  │ ├── probe.js vendored connection probe (from mcp-client/src/probe.ts)
56
62
  │ ├── transport.js vendored transport factory (from mcp-client/src/transport.ts)
57
63
  │ └── client.js browser half (esbuild bundle, ModuleLoader wire format)
@@ -124,6 +130,18 @@ Then **restart `dsh web`** (client roster changes require a restart); afterwards
124
130
  (`streamable-http` → URL / `stdio` → command), headers, tool-call timeout, etc.
125
131
  3. Click **Test connection** to verify connectivity and the tool list, then **Save**.
126
132
 
133
+ **OAuth servers** (`streamable-http` using MCP OAuth, e.g. OAuth-protected gateway services):
134
+
135
+ - Just fill in the URL and test the connection; when the server responds with a 401 + OAuth challenge,
136
+ the plugin **opens the browser automatically** for authorization.
137
+ - Log in / approve in the browser and return to DSH; the test result refreshes automatically
138
+ ("connection succeeded + tool count").
139
+ - Tokens are persisted in the credentials document (scoped by `serverName`) and refreshed automatically
140
+ by the MCP SDK (auto-renewed while active within 24h); after authorizing once, mounts and later
141
+ test connections reuse the same token — no repeated authorization.
142
+ - The first authorization needs browser interaction, so the test/connect wait budget is relaxed to
143
+ 5 minutes; non-OAuth servers are unaffected and fail fast.
144
+
127
145
  **Day-to-day management**:
128
146
 
129
147
  - **Enable / disable**: row button; disabling a server unregisters all of its tools immediately.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-mcp — MCP 服务器管理插件(独立版)
1
+ # dsh-mcp — MCP 管理界面 + tool search:稳定工具列表、命中缓存、不撑爆上下文
2
2
 
3
3
  **[English](README.en.md) | 简体中文**
4
4
 
@@ -20,6 +20,7 @@
20
20
  - **工具级精细控制**:每个服务器展开工具列表,默认全选,可取消勾选只加载需要的部分
21
21
  - **双注入模式**:`search`(按需检索,省 token)与 `full`(全量注入)
22
22
  - **零 npm 依赖**:直接对接 DeepSeek Harness 内部能力,安装即用
23
+ - **OAuth 认证支持**:`streamable-http` 服务器若走 MCP OAuth(授权码 + PKCE),连接时自动打开浏览器授权;token 持久化、由 SDK 自动刷新(24 小时内活跃自动续期)
23
24
  - **三种安装方式**:npm / GitHub git 源 / 本地 link;中英文界面与文档
24
25
 
25
26
  ## 功能
@@ -28,6 +29,9 @@
28
29
  `@deepseek-ai/dsh-mcp-client` 实例、环境变量注入(明文入定义、secret 走 credentials)、
29
30
  连接探测(test)。
30
31
  - **Web 设置管理页**(client):Settings → MCP,列表/编辑/删除/测试服务器。
32
+ - **OAuth 认证**(host,`lib/oauth.js`):`streamable-http` 服务器遇 401 + OAuth 挑战时自动走
33
+ 授权码 + PKCE 流程,打开浏览器授权、回环回调收码、token 持久化并按需自动刷新;
34
+ 测试连接与挂载共用同一份 token。
31
35
  - **Remote 自挂载**:client 半部在 `apply()` 里自行 `ctx.remote.$mount()` 挂载 `mcpManager`
32
36
  命名空间(原实现依赖 api-remotes 的 in-box 修改,独立版不再需要任何 in-box 包改动)。
33
37
 
@@ -39,6 +43,7 @@ dsh-mcp/
39
43
  ├── lib/
40
44
  │ ├── index.js host 半部(McpManagerService,源自 mcp-manager 构建产物)
41
45
  │ ├── mcp-client.js vendored MCP 客户端(源自 @deepseek-ai/dsh-mcp-client,含工具列表稳定扩展)
46
+ │ ├── oauth.js MCP OAuth 客户端提供者(授权码 + PKCE、回环回调、token 持久化)
42
47
  │ ├── probe.js vendored 连接探测(源自 mcp-client/src/probe.ts)
43
48
  │ ├── transport.js vendored 传输工厂(源自 mcp-client/src/transport.ts)
44
49
  │ └── client.js 浏览器半部(esbuild 打包,ModuleLoader wire format)
@@ -112,6 +117,14 @@ dsh plugin --profile web add link:<本仓库绝对路径>
112
117
  (`streamable-http` 填 URL / `stdio` 填命令)、请求头、工具调用超时等
113
118
  3. 点「测试连接」确认连通性与工具列表,点「保存」
114
119
 
120
+ **OAuth 服务器**(`streamable-http` 走 MCP OAuth,如受 OAuth 保护的网关服务):
121
+
122
+ - 只需正常填写 URL 并测试连接;服务器返回 401 + OAuth 挑战时,插件**自动打开浏览器**完成授权
123
+ - 在浏览器中登录/同意后返回 DSH,测试结果自动刷新(「连接成功 + 工具数」)
124
+ - token 持久化在凭据文档(按 `serverName` 隔离),由 MCP SDK 自动刷新(24 小时内活跃自动续期);
125
+ 授权一次后,挂载连接与后续测试连接复用同一份 token,无需重复授权
126
+ - 首次授权需浏览器交互,测试/连接等待时间放宽至 5 分钟;非 OAuth 服务器不受影响,连接失败即时返回
127
+
115
128
  **日常管理**:
116
129
 
117
130
  - **启用 / 禁用**:列表行按钮,禁用后该服务器所有工具即时注销,不再注入
package/lib/client.js CHANGED
@@ -14905,7 +14905,9 @@ function emptyDraft() {
14905
14905
  headersText: "",
14906
14906
  env: [],
14907
14907
  toolCallTimeoutMs: String(DEFAULT_TOOL_CALL_TIMEOUT_MS),
14908
- failOnStartupError: false
14908
+ // Reject the mount by default when the startup connection fails, so a
14909
+ // broken server never registers stale tools.
14910
+ failOnStartupError: true
14909
14911
  };
14910
14912
  }
14911
14913
  function draftFromServer(server) {
@@ -14969,6 +14971,20 @@ function draftToSubmission(draft) {
14969
14971
  }))
14970
14972
  };
14971
14973
  }
14974
+ function failureLocaleKey(code) {
14975
+ switch (code) {
14976
+ case "MCP_SERVER_NAME_CONFLICT":
14977
+ return "serverNameConflict";
14978
+ case "MCP_SERVER_NOT_FOUND":
14979
+ return "notFound";
14980
+ case "MCP_INVALID_SPEC":
14981
+ return "invalidSpec";
14982
+ case "MCP_MOUNT_FAILED":
14983
+ return "mountFailed";
14984
+ default:
14985
+ return "failureTitle";
14986
+ }
14987
+ }
14972
14988
  function createMcpManagerStore() {
14973
14989
  return (0, import_client.defineStore)({
14974
14990
  init: () => ({
@@ -15024,7 +15040,7 @@ function createMcpManagerStore() {
15024
15040
  const style = document.createElement("style");
15025
15041
  style.setAttribute("data-plugin", "dsh-mcp");
15026
15042
  style.setAttribute("data-file", "ServerForm.module.css");
15027
- style.textContent = ".form {\n display: flex;\n flex-direction: column;\n gap: 14px;\n width: 100%;\n max-width: 760px;\n color: var(--dsw-alias-label-primary);\n}\n\n.titleRow {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n\n.title {\n margin: 0;\n font-size: 15px;\n line-height: 22px;\n font-weight: 600;\n}\n\n.titleActions {\n display: flex;\n gap: 8px;\n}\n\nbutton {\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n padding: 5px 12px;\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n line-height: 20px;\n cursor: pointer;\n}\n\nbutton:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nbutton:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary);\n outline-offset: 1px;\n}\n\nbutton:disabled {\n cursor: not-allowed;\n opacity: 0.55;\n}\n\n.primary {\n border-color: var(--dsw-alias-state-business-primary);\n background: var(--dsw-alias-state-business-primary);\n color: var(--dsw-alias-label-on-accent);\n}\n\n.primary:hover {\n background: var(--dsw-alias-state-business-primary-hover);\n}\n\n.danger {\n color: var(--dsw-alias-state-error-primary);\n}\n\n.error {\n margin: 0;\n color: var(--dsw-alias-state-error-primary);\n font-size: 13px;\n line-height: 20px;\n}\n\n.field {\n display: flex;\n flex-direction: column;\n gap: 5px;\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.field input,\n.field select,\n.field textarea {\n width: 100%;\n box-sizing: border-box;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n padding: 6px 10px;\n outline: none;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n resize: vertical;\n}\n\n.field input::placeholder,\n.field textarea::placeholder {\n color: var(--dsw-alias-label-tertiary);\n}\n\n.field input:focus-visible,\n.field select:focus-visible,\n.field textarea:focus-visible {\n border-color: var(--dsw-alias-state-business-primary);\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);\n}\n\n.envBlock {\n display: flex;\n flex-direction: column;\n gap: 8px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n padding: 10px 12px 12px;\n margin: 0;\n}\n\n.envBlock legend {\n padding: 0 4px;\n font-size: 13px;\n line-height: 20px;\n font-weight: 600;\n}\n\n.hint,\n.muted {\n margin: 0;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.envRow {\n display: grid;\n grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.6fr) auto auto;\n align-items: center;\n gap: 8px;\n}\n\n.envRow input {\n box-sizing: border-box;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n padding: 5px 8px;\n outline: none;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n}\n\n.secretToggle {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n line-height: 18px;\n white-space: nowrap;\n cursor: pointer;\n}\n\n.checkRow {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-secondary);\n cursor: pointer;\n}\n\n.testResult {\n margin: 0;\n font-size: 13px;\n line-height: 20px;\n}\n\n.testOk {\n color: var(--dsw-alias-state-success-primary);\n}\n\n.testFail {\n color: var(--dsw-alias-state-error-primary);\n}\n\n.actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n";
15043
+ style.textContent = ".form {\n display: flex;\n flex-direction: column;\n gap: 14px;\n width: 100%;\n max-width: 760px;\n color: var(--dsw-alias-label-primary);\n}\n\n.titleRow {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n\n.title {\n margin: 0;\n font-size: 15px;\n line-height: 22px;\n font-weight: 600;\n}\n\n.titleActions {\n display: flex;\n gap: 8px;\n}\n\nbutton {\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n padding: 5px 12px;\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n line-height: 20px;\n cursor: pointer;\n}\n\nbutton:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nbutton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\nbutton:disabled {\n cursor: not-allowed;\n opacity: 0.55;\n}\n\n.primary {\n border-color: var(--dsw-alias-button-primary-fill);\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n\n.primary:hover {\n background: var(--dsw-alias-button-primary-hover);\n}\n\n.danger {\n color: var(--dsw-alias-state-error-primary);\n}\n\n.error {\n margin: 0;\n color: var(--dsw-alias-state-error-primary);\n font-size: 13px;\n line-height: 20px;\n}\n\n.field {\n display: flex;\n flex-direction: column;\n gap: 5px;\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.field input,\n.field select,\n.field textarea {\n width: 100%;\n box-sizing: border-box;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n padding: 6px 10px;\n outline: none;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n resize: vertical;\n}\n\n.field input::placeholder,\n.field textarea::placeholder {\n color: var(--dsw-alias-label-tertiary);\n}\n\n.field input:focus-visible,\n.field select:focus-visible,\n.field textarea:focus-visible {\n border-color: var(--dsw-alias-brand-primary);\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);\n}\n\n.envBlock {\n display: flex;\n flex-direction: column;\n gap: 8px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n padding: 10px 12px 12px;\n margin: 0;\n}\n\n.envBlock legend {\n padding: 0 4px;\n font-size: 13px;\n line-height: 20px;\n font-weight: 600;\n}\n\n.hint,\n.muted {\n margin: 0;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.envRow {\n display: grid;\n grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.6fr) auto auto;\n align-items: center;\n gap: 8px;\n}\n\n.envRow input {\n box-sizing: border-box;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n padding: 5px 8px;\n outline: none;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n}\n\n.secretToggle {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n line-height: 18px;\n white-space: nowrap;\n cursor: pointer;\n}\n\n.checkRow {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-secondary);\n cursor: pointer;\n}\n\n.testResult {\n margin: 0;\n font-size: 13px;\n line-height: 20px;\n}\n\n.testOk {\n color: var(--dsw-alias-state-success-primary);\n}\n\n.testFail {\n color: var(--dsw-alias-state-error-primary);\n}\n\n.actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n";
15028
15044
  document.head.appendChild(style);
15029
15045
  }
15030
15046
  }
@@ -15034,7 +15050,7 @@ var ServerForm_default = { "form": "form", "titleRow": "titleRow", "title": "tit
15034
15050
  // src/client/ServerForm.tsx
15035
15051
  var import_jsx_runtime = require("react/jsx-runtime");
15036
15052
  function McpServerForm(props) {
15037
- const { draft, busy, testRunning, test, t, actions, injected } = props;
15053
+ const { draft, busy, testRunning, test, t, actions, injected, onSaved } = props;
15038
15054
  const [saveError, setSaveError] = (0, import_react.useState)(null);
15039
15055
  const update = (patch) => {
15040
15056
  setSaveError(null);
@@ -15066,11 +15082,12 @@ function McpServerForm(props) {
15066
15082
  try {
15067
15083
  const failure = await injected.save(draft);
15068
15084
  if (failure !== null) {
15069
- setSaveError(failure.message);
15085
+ setSaveError(t(failureLocaleKey(failure.code)));
15070
15086
  return;
15071
15087
  }
15072
15088
  await refresh();
15073
15089
  actions.cancelEdit();
15090
+ onSaved?.();
15074
15091
  } catch {
15075
15092
  setSaveError(t("failureTitle"));
15076
15093
  } finally {
@@ -15285,6 +15302,7 @@ function McpServerForm(props) {
15285
15302
  t("enabled")
15286
15303
  ] }),
15287
15304
  test !== null ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: `${ServerForm_default.testResult} ${probe !== void 0 && probe.ok ? ServerForm_default.testOk : ServerForm_default.testFail}`, role: "status", children: probe !== void 0 && probe.ok ? `${t("testOk")} (${probe.tools.length} ${t("toolCount")}, ${test.elapsedMs}ms)` : probe !== void 0 && !probe.ok ? `${t("testFail")}: ${probe.message}` : t("testUnknown") }) : null,
15305
+ testRunning && draft.transport === "streamable-http" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: ServerForm_default.muted, role: "status", children: t("testBrowserHint") }) : null,
15288
15306
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: ServerForm_default.actions, children: [
15289
15307
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: testRunning || busy !== null, onClick: () => void runTest(), children: testRunning ? t("testRunning") : t("test") }),
15290
15308
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "submit", className: ServerForm_default.primary, disabled: busy !== null || testRunning, children: busy === "save" ? t("saving") : t("save") }),
@@ -15301,7 +15319,7 @@ function McpServerForm(props) {
15301
15319
  const style = document.createElement("style");
15302
15320
  style.setAttribute("data-plugin", "dsh-mcp");
15303
15321
  style.setAttribute("data-file", "McpSettingsSection.module.css");
15304
- style.textContent = ".section {\n display: flex;\n flex-direction: column;\n gap: 14px;\n width: 100%;\n max-width: 760px;\n color: var(--dsw-alias-label-primary);\n}\n\n.header {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n}\n\n.status,\n.failure p {\n margin: 0;\n}\n\n.status,\n.failure {\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.failure {\n display: flex;\n align-items: center;\n gap: 10px;\n color: var(--dsw-alias-state-error-primary);\n}\n\nbutton {\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n padding: 5px 12px;\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n line-height: 20px;\n cursor: pointer;\n}\n\nbutton:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nbutton:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary);\n outline-offset: 1px;\n}\n\nbutton:disabled {\n cursor: not-allowed;\n opacity: 0.55;\n}\n\n.primary {\n border-color: var(--dsw-alias-state-business-primary);\n background: var(--dsw-alias-state-business-primary);\n color: var(--dsw-alias-label-on-accent);\n}\n\n.primary:hover {\n background: var(--dsw-alias-state-business-primary-hover);\n}\n\n.list {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n padding: 10px 14px;\n background: var(--dsw-alias-bg-layer-3);\n}\n\n.rowMain {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 0;\n}\n\n.rowTitle {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n}\n\n.serverName {\n overflow: hidden;\n font-size: 14px;\n line-height: 20px;\n font-weight: 600;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.badge {\n display: inline-flex;\n align-items: center;\n min-height: 18px;\n border-radius: 5px;\n padding: 1px 6px;\n font-size: 11px;\n line-height: 16px;\n white-space: nowrap;\n}\n\n.badge.mounting {\n background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent);\n color: var(--dsw-alias-state-business-primary);\n}\n\n.badge.live {\n background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);\n color: var(--dsw-alias-state-success-primary);\n}\n\n.badge.failed {\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);\n color: var(--dsw-alias-state-error-primary);\n}\n\n.badge.stopped {\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-tertiary);\n}\n\n.muted {\n color: var(--dsw-alias-label-tertiary);\n}\n\n.rowMeta {\n display: flex;\n gap: 14px;\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n font-variant-numeric: tabular-nums;\n}\n\n.rowActions {\n flex: none;\n}\n\n.card {\n display: flex;\n flex-direction: column;\n gap: 0;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-3);\n}\n\n.card > .row {\n border: 0;\n border-radius: 0;\n background: transparent;\n}\n\n.modeRow {\n display: flex;\n align-items: center;\n gap: 18px;\n flex-wrap: wrap;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n padding: 8px 14px;\n background: var(--dsw-alias-bg-layer-3);\n font-size: 13px;\n line-height: 20px;\n}\n\n.modeLabel {\n font-weight: 600;\n}\n\n.modeOption {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n cursor: pointer;\n}\n\n.modeHint {\n margin: 0;\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.toolPanel {\n border-top: 1px solid var(--dsw-alias-border-l2);\n padding: 10px 14px;\n}\n\n.toolGrid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));\n gap: 4px 14px;\n max-height: 320px;\n overflow-y: auto;\n}\n\n.toolRow {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n padding: 2px 0;\n font-size: 12px;\n line-height: 18px;\n cursor: pointer;\n}\n\n.toolName {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n";
15322
+ style.textContent = ".section {\n display: flex;\n flex-direction: column;\n gap: 14px;\n width: 100%;\n max-width: 760px;\n color: var(--dsw-alias-label-primary);\n}\n\n.header {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n}\n\n.status,\n.failure p {\n margin: 0;\n}\n\n.status,\n.failure {\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.failure {\n display: flex;\n align-items: center;\n gap: 10px;\n color: var(--dsw-alias-state-error-primary);\n}\n\nbutton {\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n padding: 5px 12px;\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n line-height: 20px;\n cursor: pointer;\n}\n\nbutton:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nbutton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\nbutton:disabled {\n cursor: not-allowed;\n opacity: 0.55;\n}\n\n.primary {\n border-color: var(--dsw-alias-button-primary-fill);\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n\n.primary:hover {\n background: var(--dsw-alias-button-primary-hover);\n}\n\n.list {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n padding: 10px 14px;\n background: var(--dsw-alias-bg-layer-3);\n}\n\n.rowMain {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 0;\n}\n\n.rowTitle {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n}\n\n.serverName {\n overflow: hidden;\n font-size: 14px;\n line-height: 20px;\n font-weight: 600;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.badge {\n display: inline-flex;\n align-items: center;\n min-height: 18px;\n border-radius: 5px;\n padding: 1px 6px;\n font-size: 11px;\n line-height: 16px;\n white-space: nowrap;\n}\n\n.badge.mounting {\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);\n color: var(--dsw-alias-brand-primary);\n}\n\n.badge.live {\n background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);\n color: var(--dsw-alias-state-success-primary);\n}\n\n.badge.failed {\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);\n color: var(--dsw-alias-state-error-primary);\n}\n\n.badge.stopped {\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-tertiary);\n}\n\n.muted {\n color: var(--dsw-alias-label-tertiary);\n}\n\n.rowMeta {\n display: flex;\n gap: 14px;\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n font-variant-numeric: tabular-nums;\n}\n\n.rowActions {\n flex: none;\n}\n\n.card {\n display: flex;\n flex-direction: column;\n gap: 0;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-3);\n}\n\n.card > .row {\n border: 0;\n border-radius: 0;\n background: transparent;\n}\n\n.modeRow {\n display: flex;\n align-items: center;\n gap: 18px;\n flex-wrap: wrap;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n padding: 8px 14px;\n background: var(--dsw-alias-bg-layer-3);\n font-size: 13px;\n line-height: 20px;\n}\n\n.modeLabel {\n font-weight: 600;\n}\n\n.modeOption {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n cursor: pointer;\n}\n\n.modeHint {\n margin: 0;\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.toolPanel {\n border-top: 1px solid var(--dsw-alias-border-l2);\n padding: 10px 14px;\n}\n\n.toolGrid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));\n gap: 4px 14px;\n max-height: 320px;\n overflow-y: auto;\n}\n\n.toolRow {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n padding: 2px 0;\n font-size: 12px;\n line-height: 18px;\n cursor: pointer;\n}\n\n.toolName {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n";
15305
15323
  document.head.appendChild(style);
15306
15324
  }
15307
15325
  }
@@ -15517,7 +15535,12 @@ function McpSettingsSection(props) {
15517
15535
  test: state.test,
15518
15536
  t,
15519
15537
  actions,
15520
- injected: { save, remove, test, list }
15538
+ injected: { save, remove, test, list },
15539
+ onSaved: () => {
15540
+ const serverName = state.draft.serverName;
15541
+ timersRef.current.push(window.setTimeout(() => refreshServer(serverName), 2e3));
15542
+ timersRef.current.push(window.setTimeout(() => refreshServer(serverName), 6e3));
15543
+ }
15521
15544
  }
15522
15545
  );
15523
15546
  }
@@ -15561,7 +15584,7 @@ function McpSettingsSection(props) {
15561
15584
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: McpSettingsSection_default.rowTitle, children: [
15562
15585
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: McpSettingsSection_default.serverName, children: server.serverName }),
15563
15586
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: `${McpSettingsSection_default.badge} ${McpSettingsSection_default[server.status.phase]}`, children: t(phaseKey(server.status.phase)) }),
15564
- !server.enabled ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: McpSettingsSection_default.muted, children: t("statusStopped") }) : null
15587
+ !server.enabled && server.status.phase !== "stopped" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: McpSettingsSection_default.muted, children: t("statusStopped") }) : null
15565
15588
  ] }),
15566
15589
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: McpSettingsSection_default.rowMeta, children: [
15567
15590
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: server.transport }),
@@ -15643,6 +15666,7 @@ var zh = {
15643
15666
  edit: "\u7F16\u8F91",
15644
15667
  test: "\u6D4B\u8BD5\u8FDE\u63A5",
15645
15668
  testRunning: "\u6B63\u5728\u6D4B\u8BD5\u2026",
15669
+ testBrowserHint: "\u6B63\u5728\u6D4B\u8BD5\u2026\u82E5\u5F39\u51FA\u6D4F\u89C8\u5668\u6388\u6743\u9875\uFF0C\u8BF7\u5B8C\u6210\u6388\u6743\u540E\u8FD4\u56DE\uFF0C\u7ED3\u679C\u4F1A\u81EA\u52A8\u5237\u65B0\u3002",
15646
15670
  testOk: "\u8FDE\u63A5\u6210\u529F",
15647
15671
  testFail: "\u8FDE\u63A5\u5931\u8D25",
15648
15672
  testUnknown: "\u672A\u77E5\u7ED3\u679C",
@@ -15715,6 +15739,7 @@ var en = {
15715
15739
  edit: "Edit",
15716
15740
  test: "Test connection",
15717
15741
  testRunning: "Testing\u2026",
15742
+ testBrowserHint: "Testing\u2026 if a browser authorization page opens, complete it and return; the result refreshes automatically.",
15718
15743
  testOk: "Connection succeeded",
15719
15744
  testFail: "Connection failed",
15720
15745
  testUnknown: "Unknown result",
package/lib/index.js CHANGED
@@ -2,6 +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
6
  import { probeConnection } from "./probe.js";
6
7
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
7
8
  import z from "@deepseek-ai/schemastery";
@@ -66,19 +67,7 @@ const mcpServersDomainSpec = defineDomain({
66
67
  message: "streamable-http requires an absolute http(s) URL"
67
68
  });
68
69
  }
69
- const headerNames = /* @__PURE__ */ new Set();
70
- row.headers.forEach((header, index) => {
71
- if (headerNames.has(header.name)) ctx.addIssue({
72
- code: "custom",
73
- path: [
74
- "headers",
75
- index,
76
- "name"
77
- ],
78
- message: `duplicate header '${header.name}'`
79
- });
80
- headerNames.add(header.name);
81
- });
70
+ // Same-name custom headers are allowed (matches other MCP clients).
82
71
  const envNames = /* @__PURE__ */ new Set();
83
72
  row.env.forEach((entry, index) => {
84
73
  if (envNames.has(entry.name)) ctx.addIssue({
@@ -124,11 +113,10 @@ function validateServerInput(server, env) {
124
113
  throw new McpServerValidationError("streamable-http requires an absolute http(s) URL");
125
114
  }
126
115
  if (!Number.isInteger(server.toolCallTimeoutMs) || server.toolCallTimeoutMs <= 0) throw new McpServerValidationError("toolCallTimeoutMs must be a positive integer");
127
- const headerNames = /* @__PURE__ */ new Set();
116
+ // Custom headers pass through unvalidated (same-name headers are allowed,
117
+ // matching other MCP clients); only a blank name is rejected.
128
118
  for (const header of server.headers) {
129
119
  if (header.name.trim().length === 0) throw new McpServerValidationError("header names must not be blank");
130
- if (headerNames.has(header.name)) throw new McpServerValidationError(`duplicate header '${header.name}'`);
131
- headerNames.add(header.name);
132
120
  }
133
121
  const envNames = /* @__PURE__ */ new Set();
134
122
  for (const entry of env) {
@@ -477,7 +465,13 @@ let McpManagerService = (() => {
477
465
  let handle;
478
466
  try {
479
467
  const env = await this.resolveEnv(row);
480
- handle = this.ctx.plugin(MCP_CLIENT_PLUGIN, this.toClientConfig(row, env));
468
+ // Every server gets an OAuth provider: servers that require
469
+ // OAuth (authorization-code flow) trigger browser auth on
470
+ // connect; servers that do not are unaffected. Tokens are
471
+ // keyed by serverName so a test-connection authorization is
472
+ // reused by the mount.
473
+ const authProvider = createOAuthProvider(row.serverName, this.ctx);
474
+ handle = this.ctx.plugin(MCP_CLIENT_PLUGIN, { ...this.toClientConfig(row, env), authProvider });
481
475
  } catch (error) {
482
476
  entry.phase = "failed";
483
477
  entry.error = errorText(error);
@@ -691,7 +685,13 @@ let McpManagerService = (() => {
691
685
  }
692
686
  } else if (entry.value !== void 0) env[entry.name] = entry.value;
693
687
  const startedAt = Date.now();
694
- const probe = await probeConnection(this.toClientConfig(request.server, env), { timeoutMs: this.probeTimeoutMs });
688
+ // Test connection also supports OAuth: an OAuth-protected server
689
+ // opens the browser for authorization and the probe continues after
690
+ // tokens are stored (keyed by serverName, matching the mount path).
691
+ const probe = await probeConnection(this.toClientConfig(request.server, env), {
692
+ timeoutMs: this.probeTimeoutMs,
693
+ authProvider: createOAuthProvider(request.server.serverName, this.ctx)
694
+ });
695
695
  const elapsedMs = Date.now() - startedAt;
696
696
  return {
697
697
  ok: true,
package/lib/mcp-client.js CHANGED
@@ -42,7 +42,10 @@ function createTransport(config) {
42
42
  env: buildChildEnv(config.env),
43
43
  cwd: config.cwd
44
44
  });
45
- case "streamable-http": return new StreamableHTTPClientTransport(new URL(config.url), { requestInit: { headers: config.headers } });
45
+ case "streamable-http": return new StreamableHTTPClientTransport(new URL(config.url), {
46
+ requestInit: { headers: config.headers },
47
+ ...(config.authProvider === void 0 ? {} : { authProvider: config.authProvider })
48
+ });
46
49
  }
47
50
  }
48
51
  //#endregion
@@ -476,7 +479,10 @@ function startConnection(ctx, config, policy) {
476
479
  const generation = new Client({
477
480
  name: "dsh-mcp-client",
478
481
  version: "0.0.1"
479
- }, { capabilities: {} });
482
+ }, {
483
+ capabilities: {},
484
+ ...(opts.authProvider === void 0 ? {} : { authProvider: opts.authProvider })
485
+ });
480
486
  const closed = Promise.withResolvers();
481
487
  let attemptSettled = false;
482
488
  let closeObserved = false;
package/lib/oauth.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * OAuth 2.0 (authorization-code + PKCE) client provider for MCP servers,
3
+ * implemented for the dsh-mcp host half.
4
+ *
5
+ * The MCP SDK drives the flow (metadata discovery, dynamic client registration,
6
+ * PKCE, token refresh) and only asks this provider to: persist tokens, and
7
+ * redirect the user agent to the authorization URL. This provider opens the
8
+ * system browser, hosts a loopback callback server, exchanges the returned
9
+ * code for tokens via the SDK, and stores them in the credentials document
10
+ * (per server id), so reconnects reuse the tokens and the SDK refreshes them
11
+ * transparently.
12
+ */
13
+
14
+ import { createServer } from "node:http";
15
+ import { spawn } from "node:child_process";
16
+ import {
17
+ discoverAuthorizationServerMetadata,
18
+ exchangeAuthorization,
19
+ } from "@modelcontextprotocol/sdk/client/auth.js";
20
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
21
+
22
+ /** Credential reference namespace prefix for per-server OAuth tokens. */
23
+ const OAUTH_REF_PREFIX = "DSH_MCP_OAUTH_";
24
+
25
+ /** Short stable hash so sanitized server ids cannot collide. */
26
+ function shortHash(value) {
27
+ let hash = 0;
28
+ for (let i = 0; i < value.length; i += 1) {
29
+ hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
30
+ }
31
+ return hash.toString(36);
32
+ }
33
+
34
+ /**
35
+ * One server's OAuth token credential reference.
36
+ *
37
+ * Credential ref names must match /^[A-Za-z_][A-Za-z0-9_]*$/, but managed
38
+ * server ids may contain hyphens, slashes, spaces, etc.; sanitize the id and
39
+ * append a short stable hash of the original so distinct ids cannot collide
40
+ * (e.g. "a-b" and "a_b").
41
+ */
42
+ function oauthTokenRef(serverId) {
43
+ const safe = serverId.replace(/[^A-Za-z0-9_]/g, "_");
44
+ return credentialRef(`${OAUTH_REF_PREFIX}${safe}_${shortHash(serverId)}`);
45
+ }
46
+
47
+ /** Open the system browser to one URL, best effort. */
48
+ function openBrowser(url) {
49
+ const command =
50
+ process.platform === "darwin" ? "open"
51
+ : process.platform === "win32" ? "start"
52
+ : "xdg-open";
53
+ const child = spawn(command, [url], { detached: true, stdio: "ignore" });
54
+ child.on("error", () => {});
55
+ child.unref();
56
+ }
57
+
58
+ /**
59
+ * Build one OAuthClientProvider for a managed server.
60
+ * @param serverId - Managed server id; keys the stored token.
61
+ * @param ctx - Host context carrying the credentials service.
62
+ * @returns an MCP SDK `OAuthClientProvider` implementation.
63
+ */
64
+ export function createOAuthProvider(serverId, ctx) {
65
+ let codeVerifierValue = null;
66
+ let savedClientInfo = null;
67
+ const port = 3100 + Math.floor(Math.random() * 20000);
68
+ const redirectUrl = `http://127.0.0.1:${port}/callback`;
69
+ const log = ctx?.logger ?? console;
70
+ const tag = `mcp-oauth(${serverId})`;
71
+
72
+ return {
73
+ get redirectUrl() {
74
+ return redirectUrl;
75
+ },
76
+ clientMetadata: {
77
+ client_name: "dsh-mcp",
78
+ redirect_uris: [redirectUrl],
79
+ },
80
+ async clientInformation() {
81
+ return savedClientInfo;
82
+ },
83
+ async saveClientInformation(info) {
84
+ savedClientInfo = info;
85
+ },
86
+ async tokens() {
87
+ const hit = await ctx.credentials.resolve(oauthTokenRef(serverId));
88
+ if (hit === undefined) return undefined;
89
+ try {
90
+ return JSON.parse(hit.value);
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ },
95
+ async saveTokens(tokens) {
96
+ await ctx.credentials.set(oauthTokenRef(serverId), JSON.stringify(tokens));
97
+ },
98
+ async clearTokens() {
99
+ await ctx.credentials.unset(oauthTokenRef(serverId));
100
+ },
101
+ async saveCodeVerifier(codeVerifier) {
102
+ codeVerifierValue = codeVerifier;
103
+ },
104
+ async codeVerifier() {
105
+ return codeVerifierValue;
106
+ },
107
+ async redirectToAuthorization(authorizationUrl) {
108
+ let resolveResult;
109
+ const resultPromise = new Promise((resolve) => {
110
+ resolveResult = resolve;
111
+ });
112
+ const server = createServer((req, res) => {
113
+ const url = new URL(req.url, redirectUrl);
114
+ const code = url.searchParams.get("code");
115
+ const error = url.searchParams.get("error");
116
+ res.setHeader("content-type", "text/html; charset=utf-8");
117
+ if (error) {
118
+ res.end(`授权失败:${error},可关闭此页面并返回 DSH。`);
119
+ log.error(`${tag}: callback rejected: ${error}`);
120
+ resolveResult({ error });
121
+ return;
122
+ }
123
+ res.end("授权成功,可关闭此页面并返回 DSH。");
124
+ log.info(`${tag}: authorization callback received (code)`);
125
+ resolveResult({ code });
126
+ });
127
+ await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
128
+ log.info(`${tag}: opening browser for authorization: ${authorizationUrl}`);
129
+ openBrowser(authorizationUrl.toString());
130
+ try {
131
+ const result = await resultPromise;
132
+ if (result.error) throw new Error(`OAuth 授权失败:${result.error}`);
133
+ const authServerUrl = new URL(authorizationUrl.origin);
134
+ log.info(`${tag}: discovering authorization server metadata at ${authServerUrl}`);
135
+ const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
136
+ log.info(`${tag}: exchanging authorization code for tokens`);
137
+ const tokens = await exchangeAuthorization(authServerUrl, {
138
+ metadata,
139
+ clientInformation: savedClientInfo,
140
+ authorizationCode: result.code,
141
+ codeVerifier: codeVerifierValue,
142
+ redirectUri: redirectUrl,
143
+ });
144
+ await this.saveTokens(tokens);
145
+ log.info(`${tag}: tokens saved`);
146
+ } catch (error) {
147
+ log.error(`${tag}: OAuth exchange failed: ${error instanceof Error ? error.message : String(error)}`);
148
+ throw error;
149
+ } finally {
150
+ server.close();
151
+ log.info(`${tag}: callback server closed`);
152
+ }
153
+ },
154
+ };
155
+ }
package/lib/probe.js CHANGED
@@ -29,16 +29,33 @@ async function probeConnection(config, options = {}) {
29
29
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
30
30
  throw new Error(`probeConnection: timeoutMs must be a positive finite number, received ${String(timeoutMs)}`);
31
31
  }
32
+ // OAuth authorization is interactive (browser login), so a probe with an
33
+ // auth provider gets a generous budget; plain servers connect or fail
34
+ // immediately and never wait out the budget. 5 minutes covers slow SSO
35
+ // login + approval screens; an exhausted budget reports a failure view and
36
+ // the loopback callback keeps running, so a late authorization still stores
37
+ // the tokens and the next probe succeeds.
38
+ const OAUTH_PROBE_BUDGET_MS = 5 * 60e3;
39
+ const budgetMs = options.authProvider === void 0 ? timeoutMs : Math.max(timeoutMs, OAUTH_PROBE_BUDGET_MS);
32
40
  const client = new Client(
33
41
  { name: "dsh-mcp-client", version: "0.0.1" },
34
- { capabilities: {} }
42
+ {
43
+ capabilities: {},
44
+ ...(options.authProvider === void 0 ? {} : { authProvider: options.authProvider })
45
+ }
35
46
  );
36
47
  const timeout = new Promise((_, reject) => {
37
- const timer = setTimeout(() => reject(new Error(`connection probe timed out after ${timeoutMs}ms`)), timeoutMs);
48
+ const timer = setTimeout(() => reject(new Error(`connection probe timed out after ${budgetMs}ms`)), budgetMs);
38
49
  timer.unref();
39
50
  });
40
51
  try {
41
- return await Promise.race([runProbe(client, config), timeout]);
52
+ // The SDK performs OAuth in the transport layer, so the authProvider must
53
+ // reach the transport config (not only the Client options).
54
+ const transportConfig = {
55
+ ...config,
56
+ ...(options.authProvider === void 0 ? {} : { authProvider: options.authProvider })
57
+ };
58
+ return await Promise.race([runProbe(client, transportConfig), timeout]);
42
59
  } catch (error) {
43
60
  return { ok: false, message: probeErrorMessage(error) };
44
61
  } finally {
package/lib/transport.js CHANGED
@@ -16,7 +16,10 @@ function createTransport(config) {
16
16
  case "streamable-http":
17
17
  return new StreamableHTTPClientTransport(
18
18
  new URL(config.url),
19
- { requestInit: { headers: config.headers } }
19
+ {
20
+ requestInit: { headers: config.headers },
21
+ ...(config.authProvider === void 0 ? {} : { authProvider: config.authProvider })
22
+ }
20
23
  );
21
24
  }
22
25
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mcp",
3
- "version": "1.1.0",
4
- "description": "MCP 服务器管理插件:可视化界面管理 MCP 服务器与工具(配置/启停/刷新/测试、工具级勾选),支持按需检索(tool search)热注入,省 token。DeepSeek Harness plugin: visual MCP server & tool management with on-demand tool search injection.",
3
+ "version": "1.2.0",
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",
7
7
  "exports": {
@@ -49,7 +49,7 @@ button:hover {
49
49
  }
50
50
 
51
51
  button:focus-visible {
52
- outline: 2px solid var(--dsw-alias-state-business-primary);
52
+ outline: 2px solid var(--dsw-alias-brand-primary);
53
53
  outline-offset: 1px;
54
54
  }
55
55
 
@@ -59,13 +59,13 @@ button:disabled {
59
59
  }
60
60
 
61
61
  .primary {
62
- border-color: var(--dsw-alias-state-business-primary);
63
- background: var(--dsw-alias-state-business-primary);
64
- color: var(--dsw-alias-label-on-accent);
62
+ border-color: var(--dsw-alias-button-primary-fill);
63
+ background: var(--dsw-alias-button-primary-fill);
64
+ color: var(--dsw-alias-label-primary-foreground);
65
65
  }
66
66
 
67
67
  .primary:hover {
68
- background: var(--dsw-alias-state-business-primary-hover);
68
+ background: var(--dsw-alias-button-primary-hover);
69
69
  }
70
70
 
71
71
  .list {
@@ -123,8 +123,8 @@ button:disabled {
123
123
  }
124
124
 
125
125
  .badge.mounting {
126
- background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent);
127
- color: var(--dsw-alias-state-business-primary);
126
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);
127
+ color: var(--dsw-alias-brand-primary);
128
128
  }
129
129
 
130
130
  .badge.live {
@@ -248,6 +248,14 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
248
248
  t={t}
249
249
  actions={actions}
250
250
  injected={{ save, remove, test, list }}
251
+ onSaved={() => {
252
+ // Mounting is asynchronous: refresh again after the connection
253
+ // settles so the badge and tool count reflect the live state without
254
+ // a manual refresh (mirrors the enable-toggle path).
255
+ const serverName = state.draft.serverName
256
+ timersRef.current.push(window.setTimeout(() => refreshServer(serverName), 2000))
257
+ timersRef.current.push(window.setTimeout(() => refreshServer(serverName), 6000))
258
+ }}
251
259
  />
252
260
  )
253
261
  }
@@ -298,7 +306,7 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
298
306
  <div className={css.rowTitle}>
299
307
  <span className={css.serverName}>{server.serverName}</span>
300
308
  <span className={`${css.badge} ${css[server.status.phase]}`}>{t(phaseKey(server.status.phase))}</span>
301
- {!server.enabled ? <span className={css.muted}>{t('statusStopped')}</span> : null}
309
+ {!server.enabled && server.status.phase !== 'stopped' ? <span className={css.muted}>{t('statusStopped')}</span> : null}
302
310
  </div>
303
311
  <div className={css.rowMeta}>
304
312
  <span>{server.transport}</span>
@@ -43,7 +43,7 @@ button:hover {
43
43
  }
44
44
 
45
45
  button:focus-visible {
46
- outline: 2px solid var(--dsw-alias-state-business-primary);
46
+ outline: 2px solid var(--dsw-alias-brand-primary);
47
47
  outline-offset: 1px;
48
48
  }
49
49
 
@@ -53,13 +53,13 @@ button:disabled {
53
53
  }
54
54
 
55
55
  .primary {
56
- border-color: var(--dsw-alias-state-business-primary);
57
- background: var(--dsw-alias-state-business-primary);
58
- color: var(--dsw-alias-label-on-accent);
56
+ border-color: var(--dsw-alias-button-primary-fill);
57
+ background: var(--dsw-alias-button-primary-fill);
58
+ color: var(--dsw-alias-label-primary-foreground);
59
59
  }
60
60
 
61
61
  .primary:hover {
62
- background: var(--dsw-alias-state-business-primary-hover);
62
+ background: var(--dsw-alias-button-primary-hover);
63
63
  }
64
64
 
65
65
  .danger {
@@ -106,8 +106,8 @@ button:disabled {
106
106
  .field input:focus-visible,
107
107
  .field select:focus-visible,
108
108
  .field textarea:focus-visible {
109
- border-color: var(--dsw-alias-state-business-primary);
110
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);
109
+ border-color: var(--dsw-alias-brand-primary);
110
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);
111
111
  }
112
112
 
113
113
  .envBlock {
@@ -3,7 +3,7 @@ import type { McpServerId, McpServerView } from './types.ts'
3
3
  import type {
4
4
  McpDraft, McpEnvRowDraft, McpTestOutcome,
5
5
  } from './mcp-store.ts'
6
- import { createEnvRowDraft } from './mcp-store.ts'
6
+ import { createEnvRowDraft, failureLocaleKey } from './mcp-store.ts'
7
7
  import type { McpSettingsLocaleKey } from './locales.ts'
8
8
  import css from './ServerForm.module.css'
9
9
 
@@ -34,11 +34,13 @@ export interface McpServerFormProps {
34
34
  t: (key: McpSettingsLocaleKey) => string
35
35
  actions: McpServerFormActions
36
36
  injected: McpServerFormRemote
37
+ /** Called after a successful save, before the editor closes. */
38
+ onSaved?: () => void
37
39
  }
38
40
 
39
41
  /** Render the editor for one server draft. */
40
42
  export function McpServerForm(props: McpServerFormProps): ReactNode {
41
- const { draft, busy, testRunning, test, t, actions, injected } = props
43
+ const { draft, busy, testRunning, test, t, actions, injected, onSaved } = props
42
44
  const [saveError, setSaveError] = useState<string | null>(null)
43
45
 
44
46
  const update = (patch: Partial<McpDraft>): void => {
@@ -76,11 +78,12 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
76
78
  try {
77
79
  const failure = await injected.save(draft)
78
80
  if (failure !== null) {
79
- setSaveError(failure.message)
81
+ setSaveError(t(failureLocaleKey(failure.code)))
80
82
  return
81
83
  }
82
84
  await refresh()
83
85
  actions.cancelEdit()
86
+ onSaved?.()
84
87
  } catch {
85
88
  setSaveError(t('failureTitle'))
86
89
  } finally {
@@ -287,6 +290,10 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
287
290
  </p>
288
291
  ) : null}
289
292
 
293
+ {testRunning && draft.transport === 'streamable-http' ? (
294
+ <p className={css.muted} role="status">{t('testBrowserHint')}</p>
295
+ ) : null}
296
+
290
297
  <div className={css.actions}>
291
298
  <button type="button" disabled={testRunning || busy !== null} onClick={() => void runTest()}>
292
299
  {testRunning ? t('testRunning') : t('test')}
@@ -43,6 +43,7 @@ export const zh = {
43
43
  edit: '编辑',
44
44
  test: '测试连接',
45
45
  testRunning: '正在测试…',
46
+ testBrowserHint: '正在测试…若弹出浏览器授权页,请完成授权后返回,结果会自动刷新。',
46
47
  testOk: '连接成功',
47
48
  testFail: '连接失败',
48
49
  testUnknown: '未知结果',
@@ -120,6 +121,7 @@ export const en = {
120
121
  edit: 'Edit',
121
122
  test: 'Test connection',
122
123
  testRunning: 'Testing…',
124
+ testBrowserHint: 'Testing… if a browser authorization page opens, complete it and return; the result refreshes automatically.',
123
125
  testOk: 'Connection succeeded',
124
126
  testFail: 'Connection failed',
125
127
  testUnknown: 'Unknown result',
@@ -102,7 +102,9 @@ export function emptyDraft(): McpDraft {
102
102
  headersText: '',
103
103
  env: [],
104
104
  toolCallTimeoutMs: String(DEFAULT_TOOL_CALL_TIMEOUT_MS),
105
- failOnStartupError: false,
105
+ // Reject the mount by default when the startup connection fails, so a
106
+ // broken server never registers stale tools.
107
+ failOnStartupError: true,
106
108
  }
107
109
  }
108
110