dsh-mcp 1.1.0 → 1.3.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,36 @@ 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.3.0] - 2026-08-16
11
+
12
+ ### Added
13
+
14
+ - **Windows working-directory support**: stdio servers now accept drive-letter absolute paths for `cwd` (e.g. `C:\Users\...`, `C:/...`), consistent with POSIX `/` and UNC `\\` paths (PR #2, thanks @coding-chong)
15
+
16
+ ### Fixed
17
+
18
+ - Form operations now surface the real error: save / delete / test-connection failures show `code: message` (e.g. `MCP_SERVER_NAME_CONFLICT: serverName "x" is already used...`) instead of a generic message, making failures diagnosable
19
+ - Refresh is decoupled from save/delete: a `refresh()` failure no longer misreports the save/delete outcome — the editor stays open and shows the refresh failure reason (`refresh()` keeps its try/catch and returns a result)
20
+ - Removed the now-unused `failureLocaleKey` dead code (error display shows `code: message` directly)
21
+
22
+ ## [1.2.0] - 2026-08-16
23
+
24
+ ### Added
25
+
26
+ - **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`)
27
+
28
+ ### Fixed
29
+
30
+ - 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
31
+ - 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
32
+ - Disabled servers no longer render two "Disabled" badges (the phase badge plus a redundant caption)
33
+ - 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`)
34
+
35
+ ### Improved
36
+
37
+ - 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
38
+ - After saving a server, the list refreshes itself on a delay so "Connecting" settles to "Connected" once the mount is live
39
+
10
40
  ## [1.1.0] - 2026-08-15
11
41
 
12
42
  ### Added
package/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@
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.3.0] - 2026-08-16
11
+
12
+ ### 新增
13
+
14
+ - **Windows 工作目录支持**:stdio 服务器的 cwd 接受 Windows 盘符绝对路径(如 `C:\Users\...`、`C:/...`),与 POSIX `/`、UNC `\\` 路径一致(PR #2,感谢 @coding-chong)
15
+
16
+ ### 修复
17
+
18
+ - 表单操作失败时展示真实错误信息:保存 / 删除 / 测试连接失败不再只显示笼统文案,直接展示 `code: message`(如 `MCP_SERVER_NAME_CONFLICT: serverName "x" is already used...`),便于定位问题
19
+ - 刷新与保存/删除解耦:`refresh()` 失败不再误报保存结果,停留编辑页并显示刷新失败原因(`refresh()` 保留 try/catch 并返回结果)
20
+ - 清理死代码:移除已无引用的 `failureLocaleKey`(错误展示改为直接显示 `code: message`)
21
+
22
+ ## [1.2.0] - 2026-08-16
23
+
24
+ ### 新增
25
+
26
+ - **OAuth 认证支持**:`streamable-http` 服务器支持 MCP OAuth(授权码 + PKCE),连接时自动打开浏览器授权;token 持久化(凭据文档)并由 SDK 自动刷新(24 小时内活跃自动续期)(`lib/oauth.js`)
27
+
28
+ ### 修复
29
+
30
+ - OAuth token 凭据引用名与服务器名中的连字符冲突导致 `resolve` 校验失败(凭据引用名仅允许 `[A-Za-z_][A-Za-z0-9_]*`):引用名改为清洗后的服务器名 + 稳定哈希,避免非法字符与命名碰撞
31
+ - OAuth 交互授权探测预算从 90 秒提升到 5 分钟:首次授权需在浏览器完成登录/同意,慢于 90 秒会导致探测提前超时并误报连接失败(授权其实已成功、token 已保存),现授权完成后测试结果会自动展示
32
+ - 禁用状态的服务器不再重复展示两个「未启用」徽标(badge 与补充文案叠加)
33
+ - 设置页 primary 按钮(添加/保存)与「连接中」徽标使用了不存在的主题 token,导致文字颜色异常:改用 web shell 真实主题 token(`--dsw-alias-button-primary-fill` / `--dsw-alias-label-primary-foreground` / `--dsw-alias-brand-primary`)
34
+
35
+ ### 优化
36
+
37
+ - 测试连接(streamable-http)期间提示浏览器授权:若弹出授权页,完成授权后返回,结果自动刷新
38
+ - 保存服务器后自动延迟刷新列表状态,挂载完成后「连接中」自动变为「已连接」
39
+
10
40
  ## [1.1.0] - 2026-08-15
11
41
 
12
42
  ### 新增
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) {
@@ -15024,7 +15026,7 @@ function createMcpManagerStore() {
15024
15026
  const style = document.createElement("style");
15025
15027
  style.setAttribute("data-plugin", "dsh-mcp");
15026
15028
  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";
15029
+ 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
15030
  document.head.appendChild(style);
15029
15031
  }
15030
15032
  }
@@ -15034,7 +15036,7 @@ var ServerForm_default = { "form": "form", "titleRow": "titleRow", "title": "tit
15034
15036
  // src/client/ServerForm.tsx
15035
15037
  var import_jsx_runtime = require("react/jsx-runtime");
15036
15038
  function McpServerForm(props) {
15037
- const { draft, busy, testRunning, test, t, actions, injected } = props;
15039
+ const { draft, busy, testRunning, test, t, actions, injected, onSaved } = props;
15038
15040
  const [saveError, setSaveError] = (0, import_react.useState)(null);
15039
15041
  const update = (patch) => {
15040
15042
  setSaveError(null);
@@ -15049,13 +15051,16 @@ function McpServerForm(props) {
15049
15051
  const addEnvRow = () => {
15050
15052
  update({ env: [...draft.env, createEnvRowDraft()] });
15051
15053
  };
15054
+ const errorText = (error51) => error51 instanceof Error ? error51.message : String(error51);
15052
15055
  const runTest = async () => {
15053
15056
  setSaveError(null);
15054
15057
  actions.setTestRunning(true);
15055
15058
  try {
15056
15059
  actions.setTest(await injected.test(draft));
15057
- } catch {
15060
+ } catch (error51) {
15061
+ console.error("[dsh-mcp] test failed:", error51);
15058
15062
  actions.setTest(null);
15063
+ setSaveError(errorText(error51));
15059
15064
  } finally {
15060
15065
  actions.setTestRunning(false);
15061
15066
  }
@@ -15066,13 +15071,15 @@ function McpServerForm(props) {
15066
15071
  try {
15067
15072
  const failure = await injected.save(draft);
15068
15073
  if (failure !== null) {
15069
- setSaveError(failure.message);
15074
+ setSaveError(`${failure.code}: ${failure.message}`);
15070
15075
  return;
15071
15076
  }
15072
- await refresh();
15077
+ if (!await refresh()) return;
15073
15078
  actions.cancelEdit();
15074
- } catch {
15075
- setSaveError(t("failureTitle"));
15079
+ onSaved?.();
15080
+ } catch (error51) {
15081
+ console.error("[dsh-mcp] save failed:", error51);
15082
+ setSaveError(errorText(error51));
15076
15083
  } finally {
15077
15084
  actions.setBusy(null);
15078
15085
  }
@@ -15084,13 +15091,14 @@ function McpServerForm(props) {
15084
15091
  try {
15085
15092
  const failure = await injected.remove(draft.id);
15086
15093
  if (failure !== null) {
15087
- setSaveError(failure.message);
15094
+ setSaveError(`${failure.code}: ${failure.message}`);
15088
15095
  return;
15089
15096
  }
15090
- await refresh();
15097
+ if (!await refresh()) return;
15091
15098
  actions.cancelEdit();
15092
- } catch {
15093
- setSaveError(t("failureTitle"));
15099
+ } catch (error51) {
15100
+ console.error("[dsh-mcp] remove failed:", error51);
15101
+ setSaveError(errorText(error51));
15094
15102
  } finally {
15095
15103
  actions.setBusy(null);
15096
15104
  }
@@ -15098,7 +15106,11 @@ function McpServerForm(props) {
15098
15106
  const refresh = async () => {
15099
15107
  try {
15100
15108
  actions.setServers(await injected.list());
15101
- } catch {
15109
+ return true;
15110
+ } catch (error51) {
15111
+ console.error("[dsh-mcp] refresh failed:", error51);
15112
+ setSaveError(errorText(error51));
15113
+ return false;
15102
15114
  }
15103
15115
  };
15104
15116
  const stdio = draft.transport === "stdio";
@@ -15285,6 +15297,7 @@ function McpServerForm(props) {
15285
15297
  t("enabled")
15286
15298
  ] }),
15287
15299
  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,
15300
+ testRunning && draft.transport === "streamable-http" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: ServerForm_default.muted, role: "status", children: t("testBrowserHint") }) : null,
15288
15301
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: ServerForm_default.actions, children: [
15289
15302
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: testRunning || busy !== null, onClick: () => void runTest(), children: testRunning ? t("testRunning") : t("test") }),
15290
15303
  /* @__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 +15314,7 @@ function McpServerForm(props) {
15301
15314
  const style = document.createElement("style");
15302
15315
  style.setAttribute("data-plugin", "dsh-mcp");
15303
15316
  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";
15317
+ 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
15318
  document.head.appendChild(style);
15306
15319
  }
15307
15320
  }
@@ -15517,7 +15530,12 @@ function McpSettingsSection(props) {
15517
15530
  test: state.test,
15518
15531
  t,
15519
15532
  actions,
15520
- injected: { save, remove, test, list }
15533
+ injected: { save, remove, test, list },
15534
+ onSaved: () => {
15535
+ const serverName = state.draft.serverName;
15536
+ timersRef.current.push(window.setTimeout(() => refreshServer(serverName), 2e3));
15537
+ timersRef.current.push(window.setTimeout(() => refreshServer(serverName), 6e3));
15538
+ }
15521
15539
  }
15522
15540
  );
15523
15541
  }
@@ -15561,7 +15579,7 @@ function McpSettingsSection(props) {
15561
15579
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: McpSettingsSection_default.rowTitle, children: [
15562
15580
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: McpSettingsSection_default.serverName, children: server.serverName }),
15563
15581
  /* @__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
15582
+ !server.enabled && server.status.phase !== "stopped" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: McpSettingsSection_default.muted, children: t("statusStopped") }) : null
15565
15583
  ] }),
15566
15584
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: McpSettingsSection_default.rowMeta, children: [
15567
15585
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: server.transport }),
@@ -15643,6 +15661,7 @@ var zh = {
15643
15661
  edit: "\u7F16\u8F91",
15644
15662
  test: "\u6D4B\u8BD5\u8FDE\u63A5",
15645
15663
  testRunning: "\u6B63\u5728\u6D4B\u8BD5\u2026",
15664
+ 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
15665
  testOk: "\u8FDE\u63A5\u6210\u529F",
15647
15666
  testFail: "\u8FDE\u63A5\u5931\u8D25",
15648
15667
  testUnknown: "\u672A\u77E5\u7ED3\u679C",
@@ -15715,6 +15734,7 @@ var en = {
15715
15734
  edit: "Edit",
15716
15735
  test: "Test connection",
15717
15736
  testRunning: "Testing\u2026",
15737
+ testBrowserHint: "Testing\u2026 if a browser authorization page opens, complete it and return; the result refreshes automatically.",
15718
15738
  testOk: "Connection succeeded",
15719
15739
  testFail: "Connection failed",
15720
15740
  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({
@@ -116,7 +105,7 @@ function validateServerInput(server, env) {
116
105
  if (!serverNameSchema.safeParse(server.serverName).success) throw new McpServerValidationError("serverName must match [A-Za-z0-9_-]{1,32}");
117
106
  if (server.transport === "stdio") {
118
107
  if (server.command.trim().length === 0) throw new McpServerValidationError("stdio transport requires a command");
119
- if (server.cwd.length > 0 && !/^[/\\]/.test(server.cwd)) throw new McpServerValidationError("cwd must be an absolute path or empty");
108
+ if (server.cwd.length > 0 && !/^(?:[A-Za-z]:[\\/]|[/\\])/.test(server.cwd)) throw new McpServerValidationError("cwd must be an absolute path or empty");
120
109
  } else try {
121
110
  const parsed = new URL(server.url);
122
111
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("non-http protocol");
@@ -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.3.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 {
@@ -1,5 +1,5 @@
1
1
  import { useState, type ReactNode } from 'react'
2
- import type { McpServerId, McpServerView } from './types.ts'
2
+ import type { McpManagerFailure, McpServerId, McpServerView } from './types.ts'
3
3
  import type {
4
4
  McpDraft, McpEnvRowDraft, McpTestOutcome,
5
5
  } from './mcp-store.ts'
@@ -9,8 +9,8 @@ import css from './ServerForm.module.css'
9
9
 
10
10
  /** The Remote verbs the editor needs; structurally the section's inject face. */
11
11
  export interface McpServerFormRemote {
12
- save: (draft: McpDraft) => Promise<{ message: string } | null>
13
- remove: (id: McpServerId) => Promise<{ message: string } | null>
12
+ save: (draft: McpDraft) => Promise<McpManagerFailure | null>
13
+ remove: (id: McpServerId) => Promise<McpManagerFailure | null>
14
14
  test: (draft: McpDraft) => Promise<McpTestOutcome>
15
15
  list: () => Promise<readonly McpServerView[]>
16
16
  }
@@ -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 => {
@@ -58,13 +60,18 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
58
60
  update({ env: [...draft.env, createEnvRowDraft()] })
59
61
  }
60
62
 
63
+ const errorText = (error: unknown): string =>
64
+ error instanceof Error ? error.message : String(error)
65
+
61
66
  const runTest = async (): Promise<void> => {
62
67
  setSaveError(null)
63
68
  actions.setTestRunning(true)
64
69
  try {
65
70
  actions.setTest(await injected.test(draft))
66
- } catch {
71
+ } catch (error) {
72
+ console.error('[dsh-mcp] test failed:', error)
67
73
  actions.setTest(null)
74
+ setSaveError(errorText(error))
68
75
  } finally {
69
76
  actions.setTestRunning(false)
70
77
  }
@@ -76,13 +83,15 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
76
83
  try {
77
84
  const failure = await injected.save(draft)
78
85
  if (failure !== null) {
79
- setSaveError(failure.message)
86
+ setSaveError(`${failure.code}: ${failure.message}`)
80
87
  return
81
88
  }
82
- await refresh()
89
+ if (!await refresh()) return
83
90
  actions.cancelEdit()
84
- } catch {
85
- setSaveError(t('failureTitle'))
91
+ onSaved?.()
92
+ } catch (error) {
93
+ console.error('[dsh-mcp] save failed:', error)
94
+ setSaveError(errorText(error))
86
95
  } finally {
87
96
  actions.setBusy(null)
88
97
  }
@@ -95,24 +104,27 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
95
104
  try {
96
105
  const failure = await injected.remove(draft.id)
97
106
  if (failure !== null) {
98
- setSaveError(failure.message)
107
+ setSaveError(`${failure.code}: ${failure.message}`)
99
108
  return
100
109
  }
101
- await refresh()
110
+ if (!await refresh()) return
102
111
  actions.cancelEdit()
103
- } catch {
104
- setSaveError(t('failureTitle'))
112
+ } catch (error) {
113
+ console.error('[dsh-mcp] remove failed:', error)
114
+ setSaveError(errorText(error))
105
115
  } finally {
106
116
  actions.setBusy(null)
107
117
  }
108
118
  }
109
119
 
110
- const refresh = async (): Promise<void> => {
120
+ const refresh = async (): Promise<boolean> => {
111
121
  try {
112
122
  actions.setServers(await injected.list())
113
- } catch {
114
- // The save/remove outcome is the actionable one; a failed refresh is
115
- // re-run on the next page visit.
123
+ return true
124
+ } catch (error) {
125
+ console.error('[dsh-mcp] refresh failed:', error)
126
+ setSaveError(errorText(error))
127
+ return false
116
128
  }
117
129
  }
118
130
 
@@ -287,6 +299,10 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
287
299
  </p>
288
300
  ) : null}
289
301
 
302
+ {testRunning && draft.transport === 'streamable-http' ? (
303
+ <p className={css.muted} role="status">{t('testBrowserHint')}</p>
304
+ ) : null}
305
+
290
306
  <div className={css.actions}>
291
307
  <button type="button" disabled={testRunning || busy !== null} onClick={() => void runTest()}>
292
308
  {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
 
@@ -186,17 +188,6 @@ export function draftToSubmission(draft: McpDraft): McpSubmission {
186
188
  }
187
189
  }
188
190
 
189
- /** Map a Remote failure code to a locale key for user-facing copy. */
190
- export function failureLocaleKey(code: string): string {
191
- switch (code) {
192
- case 'MCP_SERVER_NAME_CONFLICT': return 'serverNameConflict'
193
- case 'MCP_SERVER_NOT_FOUND': return 'notFound'
194
- case 'MCP_INVALID_SPEC': return 'invalidSpec'
195
- case 'MCP_MOUNT_FAILED': return 'mountFailed'
196
- default: return 'failureTitle'
197
- }
198
- }
199
-
200
191
  /**
201
192
  * Declares the MCP management page state and write surface.
202
193
  * @returns the store handle.