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 +30 -0
- package/CHANGELOG.md +30 -0
- package/README.en.md +19 -1
- package/README.md +14 -1
- package/lib/client.js +36 -16
- package/lib/index.js +19 -19
- package/lib/mcp-client.js +8 -2
- package/lib/oauth.js +155 -0
- package/lib/probe.js +20 -3
- package/lib/transport.js +4 -1
- package/package.json +2 -2
- package/src/client/McpSettingsSection.module.css +7 -7
- package/src/client/McpSettingsSection.tsx +9 -1
- package/src/client/ServerForm.module.css +7 -7
- package/src/client/ServerForm.tsx +33 -17
- package/src/client/locales.ts +2 -0
- package/src/client/mcp-store.ts +3 -12
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
|
|
1
|
+
# dsh-mcp — MCP management UI + tool search: stable tool list, cache hits, no context bloat
|
|
2
2
|
|
|
3
3
|
[](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
|
-
|
|
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-
|
|
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
|
-
|
|
15075
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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), {
|
|
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
|
-
}, {
|
|
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
|
-
{
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
{
|
|
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.
|
|
4
|
-
"description": "MCP
|
|
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-
|
|
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-
|
|
63
|
-
background: var(--dsw-alias-
|
|
64
|
-
color: var(--dsw-alias-label-
|
|
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-
|
|
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-
|
|
127
|
-
color: var(--dsw-alias-
|
|
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-
|
|
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-
|
|
57
|
-
background: var(--dsw-alias-
|
|
58
|
-
color: var(--dsw-alias-label-
|
|
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-
|
|
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-
|
|
110
|
-
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-
|
|
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<
|
|
13
|
-
remove: (id: McpServerId) => Promise<
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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<
|
|
120
|
+
const refresh = async (): Promise<boolean> => {
|
|
111
121
|
try {
|
|
112
122
|
actions.setServers(await injected.list())
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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')}
|
package/src/client/locales.ts
CHANGED
|
@@ -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',
|
package/src/client/mcp-store.ts
CHANGED
|
@@ -102,7 +102,9 @@ export function emptyDraft(): McpDraft {
|
|
|
102
102
|
headersText: '',
|
|
103
103
|
env: [],
|
|
104
104
|
toolCallTimeoutMs: String(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
|
105
|
-
|
|
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.
|