dsh-mcp 1.8.0 → 1.10.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,7 +7,23 @@ 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
- ## [Unreleased]
10
+ ## [1.10.0] - 2026-09-02
11
+
12
+ ### Added
13
+
14
+ - **Actionable diagnosis when the host half is missing**: when the server-list load fails because of an HTTP 404 on `/api/mcpManager/*` (plugin host half not registered, or client/host version mismatch), the page now shows a troubleshooting hint next to the raw error (verify the `cordis.patch.yml` row → restart `dsh web` → hard-refresh → upgrade both sides); the README troubleshooting section is updated as well
15
+
16
+ ### Fixed
17
+
18
+ - **Host-side copy internationalization**: the settings UI was fully bilingual but the host half (`lib/index.js`/`lib/oauth.js`/`lib/mcp-client.js`) kept model-visible copy and OAuth errors hardcoded in Chinese. The `mcp_tool_search` description/parameter docs, search-result text, the injected `mcp-tool-control` system prompt, and the OAuth authorization/callback page copy now follow the DSH `locale.preference` from the settings document (new `lib/host-locales.js` table; falls back to Chinese — the previous behavior — when the preference cannot be read)
19
+ - **OAuth decisions no longer key on message text**: the tool-call OAuth preflight used `error.message.includes("授权")` to decide whether to re-throw the link-carrying error — translating the message would silently break OAuth error propagation. It now keys on a stable error code `MCP_OAUTH_REQUIRED` (`error.code`), decoupling control flow from display text
20
+ - **Static-credential servers are no longer mistaken for OAuth**: only streamable-http servers configured for authorization-code + PKCE and WITHOUT a static `Authorization` request header get an OAuth provider; a static-token server facing a 401 now reports the authentication failure instead of starting a browser authorization flow
21
+
22
+ ## [1.9.0] - 2026-08-28
23
+
24
+ ### Added
25
+
26
+ - **Tool-list stability (better prompt-cache hits)**: tool schemas are canonicalized (recursive key sorting) before registration, so servers reordering schema keys no longer triggers dispose/re-register churn; MCP tools in the system prompt are rendered in stable name order, so the same tool set renders byte-identically no matter the hot-set or `tools/list` order
11
27
 
12
28
  ## [1.8.0] - 2026-08-27
13
29
 
package/CHANGELOG.md CHANGED
@@ -7,7 +7,23 @@
7
7
  格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
8
8
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
9
9
 
10
- ## [Unreleased]
10
+ ## [1.10.0] - 2026-09-02
11
+
12
+ ### 新增
13
+
14
+ - **设置页对 host 缺失给出可操作诊断**:服务器列表加载失败若源于 `/api/mcpManager/*` HTTP 404(插件 host 半未注册或 client/host 版本不匹配),页面除原始错误外额外显示排查指引(核对 `cordis.patch.yml` 注册行 → 重启 `dsh web` → 硬刷新 → 同步升级),README 故障排查同步补充
15
+
16
+ ### 修复
17
+
18
+ - **host 端文案国际化**:设置页双语早已齐备,但 host 半部(`lib/index.js`/`lib/oauth.js`/`lib/mcp-client.js`)的模型可见文案与 OAuth 错误一直是硬编码中文——`mcp_tool_search` 描述与参数说明、检索结果文案、注入的 `mcp-tool-control` 系统提示、OAuth 授权/回调页文案现均按 DSH `locale.preference`(settings 文档)选择 zh/en(新增 `lib/host-locales.js` 文案表;读不到时回退中文,与旧行为一致)
19
+ - **OAuth 判定不再依赖文本匹配**:`mcp-client` 的工具授权预检曾用 `error.message.includes("授权")` 决定是否重抛带链接的错误,翻译消息会静默破坏 OAuth 错误传播——现统一改用稳定的错误码 `MCP_OAUTH_REQUIRED`(`error.code`),控制流与显示文案解耦
20
+ - **静态凭据服务器不再被误当作 OAuth**:只有配置了 OAuth 授权码 + PKCE 且**没有**静态 `Authorization` 请求头的 streamable-http 服务器才挂 OAuth provider;带静态 token 的服务器遇到 401 时直接呈现认证失败,不再触发浏览器授权流程
21
+
22
+ ## [1.9.0] - 2026-08-28
23
+
24
+ ### 新增
25
+
26
+ - **工具列表稳定化增强(提升 prompt cache 命中)**:注册前对工具 schema 做规范化(递归排序键),服务器返回的键顺序变化不再导致工具被误判为变化而注销重注册;系统提示词中 MCP 工具按名称稳定排序,同一工具集合的渲染文本恒定——热注入集(search 模式每次调用都会变动)与服务器 `tools/list` 顺序不再影响提示词稳定性
11
27
 
12
28
  ## [1.8.0] - 2026-08-27
13
29
 
package/README.en.md CHANGED
@@ -160,7 +160,12 @@ Check in order:
160
160
  - Check the `dsh web` process log for `mcp-manager` initialization errors;
161
161
  - After upgrading the plugin, restart and **hard-refresh** so the old client bundle does not
162
162
  mix with the new host (typical symptom: `client api: ... 404` or `env is not iterable` — both
163
- come from mixing versions).
163
+ come from mixing versions);
164
+ - An error shaped like `transport failure for /api/mcpManager/list: HTTP 404` means the host did
165
+ not register the `mcpManager` service: usually the plugin host half is not active (missing
166
+ cordis.patch.yml row / wrong profile) or the client and host versions disagree. Verify the
167
+ registration row per Q1, confirm the install targets the `web` profile, restart, and
168
+ hard-refresh; if it persists, upgrade both `dsh web` and the plugin to the latest versions.
164
169
 
165
170
  **Q3: MCP tools do not show up in an agent session?**
166
171
 
@@ -169,6 +174,16 @@ Check in order:
169
174
  so tools not searched are absent from the system prompt by design; switch to "Full injection"
170
175
  to verify.
171
176
 
177
+ **Q4: A server with an `Authorization` header still asks for OAuth / fails to mount?**
178
+
179
+ - When an `Authorization` (static bearer/token) header is configured, dsh-mcp does NOT treat the
180
+ server as OAuth: the authorization-code + PKCE flow is enabled only for servers WITHOUT a static
181
+ `Authorization` header, so a 401 is never mistaken for an OAuth challenge that opens the browser.
182
+ For a static-token server, make sure the request headers are correct;
183
+ - If an HTTPS intranet host reports `fetch failed` / `unable to verify the first certificate`, the
184
+ host Node does not trust the internal CA: start `dsh web` with `NODE_OPTIONS=--use-system-ca`
185
+ (or add the root cert to `NODE_EXTRA_CA_CERTS`), then restart the host and hard-refresh.
186
+
172
187
  **Add a server**:
173
188
 
174
189
  1. Click **Add server** (the form expands inline above the list).
package/README.md CHANGED
@@ -142,7 +142,11 @@ dsh plugin --profile web add link:<本仓库绝对路径>
142
142
 
143
143
  - 确认 `dsh web` 进程日志中 `mcp-manager` 没有初始化错误;
144
144
  - 若升级过插件,请重启后**硬刷新**,避免旧 client bundle 与新版 host 不匹配
145
- (典型现象:操作报 `client api: ... 404` 或 `env is not iterable`,都是新旧版本混用所致)。
145
+ (典型现象:操作报 `client api: ... 404` 或 `env is not iterable`,都是新旧版本混用所致);
146
+ - 报错形如 `transport failure for /api/mcpManager/list: HTTP 404` 表示宿主端没有注册
147
+ `mcpManager` 服务:多半是插件 host 半未生效(漏了 cordis.patch.yml 注册行/装错 profile)或
148
+ client 与 host 版本不一致。请按 Q1 核对注册行、确认安装到了 `web` profile、重启后硬刷新;
149
+ 仍不行则把 `dsh web` 与插件版本都升到最新再试。
146
150
 
147
151
  **Q3:MCP 工具没有出现在 agent 会话里?**
148
152
 
@@ -150,6 +154,16 @@ dsh plugin --profile web add link:<本仓库绝对路径>
150
154
  - 注入模式为「按需检索」时,模型会通过 `mcp_tool_search` 检索后热注入,未检索到的工具不在
151
155
  系统提示词中属正常现象;可切换到「全量注入」验证。
152
156
 
157
+ **Q4:服务器配置了 Authorization 头却提示需要 OAuth 授权 / 挂载失败?**
158
+
159
+ - 只要在请求头里配置了 `Authorization`(静态 Bearer/token),dsh-mcp 就不会把它当作 OAuth
160
+ 服务器:真正的 OAuth(授权码 + PKCE)只对**没有静态 Authorization 头**的服务器启用,避免
161
+ 401 被误当成 OAuth 挑战而打开浏览器授权。若你连的是需要静态 token 的服务器,确认请求头
162
+ 正确即可;
163
+ - 若 https 内网域名报 `fetch failed` / `unable to verify the first certificate`,是宿主 Node
164
+ 不信任公司内网 CA:用 `NODE_OPTIONS=--use-system-ca` 启动 `dsh web`(或把根证书加入
165
+ `NODE_EXTRA_CA_CERTS`),再重启宿主与硬刷新浏览器。
166
+
153
167
  **添加服务器**:
154
168
 
155
169
  1. 点击「添加服务器」(表单在列表上方就地展开)
package/lib/client.js CHANGED
@@ -15042,7 +15042,7 @@ var import_react4 = require("react");
15042
15042
  var import_react = require("react");
15043
15043
 
15044
15044
  // src/client/mcp-store.ts
15045
- var import_client = require("@deepseek-ai/dsh-client-runtime/client");
15045
+ var import_dsh_client_store = require("@deepseek-ai/dsh-client-store");
15046
15046
  var DEFAULT_TOOL_CALL_TIMEOUT_MS = 6e4;
15047
15047
  function emptyDraft() {
15048
15048
  return {
@@ -15164,7 +15164,7 @@ function draftToSubmission(draft) {
15164
15164
  };
15165
15165
  }
15166
15166
  function createMcpManagerStore() {
15167
- return (0, import_client.defineStore)({
15167
+ return (0, import_dsh_client_store.defineStore)({
15168
15168
  init: () => ({
15169
15169
  loadState: "loading",
15170
15170
  servers: [],
@@ -15934,6 +15934,7 @@ function McpSettingsSection(props) {
15934
15934
  const { setLoadState, setServers, beginCreate, beginEdit, cancelEdit, updateDraft, setBusy, setTestRunning, setTest } = props.actions;
15935
15935
  const t = props.t;
15936
15936
  const [loadErrorDetail, setLoadErrorDetail] = (0, import_react4.useState)(null);
15937
+ const [hostMissing, setHostMissing] = (0, import_react4.useState)(false);
15937
15938
  const [tools, setTools] = (0, import_react4.useState)(null);
15938
15939
  const [toolsError, setToolsError] = (0, import_react4.useState)(null);
15939
15940
  const [expanded, setExpanded] = (0, import_react4.useState)(/* @__PURE__ */ new Set());
@@ -15960,7 +15961,9 @@ function McpSettingsSection(props) {
15960
15961
  };
15961
15962
  const fail = (error51) => {
15962
15963
  console.error("[dsh-mcp] list failed:", error51);
15963
- setLoadErrorDetail(String((error51 instanceof Error ? error51.message : error51) ?? error51));
15964
+ const message = String((error51 instanceof Error ? error51.message : error51) ?? error51);
15965
+ setLoadErrorDetail(message);
15966
+ setHostMissing(/HTTP 404|transport failure/.test(message));
15964
15967
  setLoadState("error");
15965
15968
  };
15966
15969
  const load = () => {
@@ -15969,6 +15972,7 @@ function McpSettingsSection(props) {
15969
15972
  setServers(servers);
15970
15973
  setLoadState("ready");
15971
15974
  setLoadErrorDetail(null);
15975
+ setHostMissing(false);
15972
15976
  },
15973
15977
  (error51) => fail(error51)
15974
15978
  );
@@ -15982,6 +15986,7 @@ function McpSettingsSection(props) {
15982
15986
  setServers(servers);
15983
15987
  setLoadState("ready");
15984
15988
  setLoadErrorDetail(null);
15989
+ setHostMissing(false);
15985
15990
  },
15986
15991
  (error51) => {
15987
15992
  if (current) fail(error51);
@@ -16026,6 +16031,7 @@ function McpSettingsSection(props) {
16026
16031
  (servers) => {
16027
16032
  setServers(servers);
16028
16033
  setLoadState("ready");
16034
+ setHostMissing(false);
16029
16035
  },
16030
16036
  () => {
16031
16037
  }
@@ -16074,6 +16080,7 @@ function McpSettingsSection(props) {
16074
16080
  if (state.loadState === "error") {
16075
16081
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: McpSettingsSection_default.failure, children: [
16076
16082
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { role: "alert", children: t("loadError") }),
16083
+ hostMissing ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: McpSettingsSection_default.muted, children: t("loadErrorHostMissing") }) : null,
16077
16084
  loadErrorDetail !== null ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("pre", { className: McpSettingsSection_default.muted, children: loadErrorDetail }) : null,
16078
16085
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", onClick: () => {
16079
16086
  setLoadState("loading");
@@ -16254,6 +16261,7 @@ var zh = {
16254
16261
  nav: "MCP",
16255
16262
  loading: "\u6B63\u5728\u8BFB\u53D6 MCP \u670D\u52A1\u5668\u2026",
16256
16263
  loadError: "\u6682\u65F6\u65E0\u6CD5\u8BFB\u53D6 MCP \u670D\u52A1\u5668\u3002",
16264
+ loadErrorHostMissing: "\u5BBF\u4E3B\u7AEF\u672A\u63D0\u4F9B mcpManager \u670D\u52A1\uFF08HTTP 404\uFF09\u3002\u901A\u5E38\u662F\u63D2\u4EF6 host \u534A\u672A\u6CE8\u518C\u6216 client/host \u7248\u672C\u4E0D\u5339\u914D\uFF1A\u8BF7\u786E\u8BA4 cordis.patch.yml \u5DF2\u6CE8\u518C dsh-mcp \u884C\u3001\u91CD\u542F dsh web \u5E76\u786C\u5237\u65B0\uFF08Cmd/Ctrl+Shift+R\uFF09\uFF1B\u82E5\u5347\u7EA7\u8FC7\u63D2\u4EF6\u8BF7\u540C\u6B65\u5347\u7EA7\u5BBF\u4E3B\u3002",
16257
16265
  retry: "\u91CD\u8BD5",
16258
16266
  empty: "\u8FD8\u6CA1\u6709\u914D\u7F6E MCP \u670D\u52A1\u5668\u3002",
16259
16267
  addServer: "\u6DFB\u52A0\u670D\u52A1\u5668",
@@ -16355,6 +16363,7 @@ var en = {
16355
16363
  nav: "MCP",
16356
16364
  loading: "Reading MCP servers\u2026",
16357
16365
  loadError: "MCP servers are temporarily unavailable.",
16366
+ loadErrorHostMissing: "The host does not expose the mcpManager service (HTTP 404). Usually the plugin host half is not registered or the client/host versions mismatch: confirm the cordis.patch.yml dsh-mcp row, restart dsh web, and hard-refresh (Cmd/Ctrl+Shift+R); after upgrading the plugin, upgrade the host to match.",
16358
16367
  retry: "Retry",
16359
16368
  empty: "No MCP servers are configured yet.",
16360
16369
  addServer: "Add server",
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Host-side copy for dsh-mcp: the strings the Host renders for operators and
3
+ * the model. The settings UI already routes every client string through the
4
+ * browser locale service (`src/client/locales.ts`); this module gives the
5
+ * host half the same zh/en split, resolved from the same DSH settings
6
+ * document (`locale.preference`), so an English deployment reads English end
7
+ * to end — including the model-facing `mcp_tool_search` description, its
8
+ * search-result text, and the injected `mcp-tool-control` system prompt.
9
+ *
10
+ * Lookup is template-based: `{name}` placeholders are substituted from the
11
+ * caller's vars object. Unknown keys and any settings-read failure fall back
12
+ * to Simplified Chinese, which matches the pre-i18n behavior.
13
+ * @module
14
+ */
15
+
16
+ /** Simplified Chinese host dictionary (source of truth). */
17
+ const zh = {
18
+ // lib/index.js — model-facing copy.
19
+ toolSearchDesc: "按关键词检索当前启用的 MCP 工具,返回匹配工具的名称/描述/参数 schema,并将命中的工具加入热注入集(下一轮模型请求即可直接调用)。需要某个 MCP 工具但不知道确切名称时使用。",
20
+ toolSearchQuery: "检索关键词,如 gitlab merge request、feishu 文档、hive 表等",
21
+ toolSearchLimit: "最多返回条数,默认 8,最大 20",
22
+ searchHit: "匹配 MCP 工具(已热启用,下一轮可直接调用):\n{lines}",
23
+ searchMiss: "未找到匹配的 MCP 工具,可换关键词重试。",
24
+ toolControlPrompt: "MCP 工具按需可用:需要某个 MCP 工具时先调用 mcp_tool_search(query) 检索,命中后该工具会自动注入当前对话;当前可用 MCP 服务器:{servers}。",
25
+ noServers: "(无)",
26
+ // lib/oauth.js — operator-facing OAuth copy.
27
+ oauthNeedLink: "服务器 \"{serverId}\" 需要 OAuth 授权,请在浏览器打开以下链接完成授权后重试(也可在 Settings → MCP 点击「测试连接」):\n{url}",
28
+ oauthFailPage: "授权失败:{error},可关闭此页面并返回 DSH。",
29
+ oauthOkPage: "授权成功,可关闭此页面并返回 DSH。",
30
+ oauthExchangeFail: "OAuth 授权失败:{error}",
31
+ // lib/mcp-client.js — operator-facing tool-call copy.
32
+ oauthToolLink: "服务器 \"{serverName}\" 需要 OAuth 授权,请在浏览器打开以下链接完成授权后重试:\n{url}",
33
+ oauthToolFallback: "服务器 \"{serverName}\" 需要 OAuth 授权:请在 Settings → MCP 编辑该服务器并点击「测试连接」完成授权后重试",
34
+ };
35
+
36
+ /** English host dictionary; every zh key must exist here. */
37
+ const en = {
38
+ toolSearchDesc: "Search the enabled MCP tools by keyword and return matching tool names/descriptions/parameter schemas; matches are hot-injected for the next model request. Use when you need an MCP tool but do not know its exact name.",
39
+ toolSearchQuery: "Search keywords, e.g. gitlab merge request, feishu document, hive table",
40
+ toolSearchLimit: "Maximum number of results; default 8, max 20",
41
+ searchHit: "Matching MCP tools (hot-enabled, callable in the next request):\n{lines}",
42
+ searchMiss: "No matching MCP tools found; try different keywords.",
43
+ toolControlPrompt: "MCP tools are available on demand: when you need an MCP tool, first call mcp_tool_search(query); a hit is hot-injected into the current conversation. Enabled MCP servers: {servers}.",
44
+ noServers: "(none)",
45
+ oauthNeedLink: "Server \"{serverId}\" requires OAuth authorization — open the link below to authorize and retry (or edit the server in Settings → MCP and click \"Test connection\"):\n{url}",
46
+ oauthFailPage: "Authorization failed: {error}. You can close this page and return to DSH.",
47
+ oauthOkPage: "Authorization succeeded. You can close this page and return to DSH.",
48
+ oauthExchangeFail: "OAuth authorization failed: {error}",
49
+ oauthToolLink: "Server \"{serverName}\" requires OAuth authorization — open the link below to authorize and retry:\n{url}",
50
+ oauthToolFallback: "Server \"{serverName}\" requires OAuth authorization: edit the server in Settings → MCP and click \"Test connection\" to complete authorization",
51
+ };
52
+
53
+ /** Key table of every host copy key. */
54
+ const KEYS = Object.keys(zh);
55
+
56
+ /**
57
+ * Substitute `{name}` placeholders with the given vars.
58
+ * @param template - template text containing `{name}` tokens.
59
+ * @param vars - placeholder values; unknown tokens are left verbatim.
60
+ * @returns the rendered string.
61
+ */
62
+ function render(template, vars) {
63
+ if (!vars) return template;
64
+ return template.replace(/\{([A-Za-z0-9_]+)\}/g, (match, name) => {
65
+ const value = vars[name];
66
+ return value === void 0 || value === null ? match : String(value);
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Resolve the DSH locale preference from the Host settings document.
72
+ * @param ctx - optional Host context whose `settings` service owns the
73
+ * `locale` section; when absent or unreadable, falls back to zh.
74
+ * @returns `'zh'` or `'en'`.
75
+ */
76
+ export function hostLocale(ctx) {
77
+ try {
78
+ const settings = ctx && typeof ctx.get === "function" ? ctx.get("settings") : void 0;
79
+ if (settings && typeof settings.get === "function") {
80
+ const section = settings.get("locale");
81
+ const preference = section && typeof section === "object" ? section.preference : void 0;
82
+ if (preference === "en" || preference === "zh") return preference;
83
+ }
84
+ } catch {
85
+ // settings unavailable (tests, headless, provider detached): keep zh.
86
+ }
87
+ return "zh";
88
+ }
89
+
90
+ /**
91
+ * Render one host copy string for the current DSH locale.
92
+ * @param ctx - Host context used to resolve the locale preference.
93
+ * @param key - copy key (see KEYS).
94
+ * @param vars - optional `{name}` placeholder values.
95
+ * @returns the localized string; unknown keys fall back to the zh template.
96
+ */
97
+ export function hostText(ctx, key, vars) {
98
+ const locale = hostLocale(ctx);
99
+ const table = locale === "en" ? en : zh;
100
+ const template = table[key] ?? zh[key] ?? "";
101
+ return render(template, vars);
102
+ }
103
+
104
+ export { en, zh };
105
+ export const __hostLocaleKeys = KEYS;
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import * as mcpClient from "./mcp-client.js";
5
5
  import { createOAuthProvider } from "./oauth.js";
6
6
  import { probeConnection } from "./probe.js";
7
7
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
8
+ import { hostText } from "./host-locales.js";
8
9
  import z from "@deepseek-ai/schemastery";
9
10
  import { z as z$1 } from "zod";
10
11
  import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
@@ -605,6 +606,21 @@ let McpManagerService = (() => {
605
606
  };
606
607
  }
607
608
  /**
609
+ * Whether one resolved client config needs an OAuth provider. Only
610
+ * streamable-http servers WITHOUT a static `Authorization` header do:
611
+ * a static bearer token means the server authenticates directly, and
612
+ * attaching an OAuth provider would make the SDK treat any 401 as an
613
+ * authorization-code challenge — opening the browser / blocking tool
614
+ * calls for servers that never intended OAuth. stdio never needs it.
615
+ * @param config - the resolved client config from {@link toClientConfig}.
616
+ * @returns true when OAuth (authorization-code + PKCE) should be enabled.
617
+ */
618
+ wantsOAuthProvider(config) {
619
+ if (config.transport !== "streamable-http") return false;
620
+ const names = Object.keys(config.headers ?? {});
621
+ return !names.some((name) => name.toLowerCase() === "authorization");
622
+ }
623
+ /**
608
624
  * Mount one server's mcp-client instance without awaiting activation, so a
609
625
  * hung server cannot block a Remote call. The mount entry's phase flips via
610
626
  * the fiber settlement callbacks.
@@ -623,14 +639,18 @@ let McpManagerService = (() => {
623
639
  try {
624
640
  const env = await this.resolveEnv(row);
625
641
  const globalEnv = await this.resolveGlobalEnv();
626
- // Every server gets an OAuth provider: servers that require
627
- // OAuth (authorization-code flow) trigger browser auth on
628
- // connect (unless allowBrowserOnMount is off, the default);
629
- // servers that do not are unaffected. Tokens are keyed by
630
- // serverName so a test-connection authorization is reused
631
- // by the mount.
632
- const authProvider = createOAuthProvider(row.serverName, this.ctx, { allowBrowser: this.allowBrowserOnMount, serverUrl: row.url });
633
- handle = this.ctx.plugin(MCP_CLIENT_PLUGIN, { ...this.toClientConfig(row, env, globalEnv), authProvider });
642
+ const config = this.toClientConfig(row, env, globalEnv);
643
+ // OAuth servers (authorization-code flow) trigger browser
644
+ // auth on connect (unless allowBrowserOnMount is off, the
645
+ // default); static-credential servers get no provider so
646
+ // their 401s surface as authentication failures instead of
647
+ // an OAuth flow the server never asked for. Tokens are
648
+ // keyed by serverName so a test-connection authorization
649
+ // is reused by the mount.
650
+ const authProvider = this.wantsOAuthProvider(config)
651
+ ? createOAuthProvider(row.serverName, this.ctx, { allowBrowser: this.allowBrowserOnMount, serverUrl: row.url })
652
+ : void 0;
653
+ handle = this.ctx.plugin(MCP_CLIENT_PLUGIN, { ...config, ...authProvider === void 0 ? {} : { authProvider } });
634
654
  } catch (error) {
635
655
  entry.phase = "failed";
636
656
  entry.error = errorText(error);
@@ -1047,9 +1067,18 @@ let McpManagerService = (() => {
1047
1067
  // opens the browser for authorization and the probe continues after
1048
1068
  // tokens are stored (keyed by serverName, matching the mount path).
1049
1069
  const globalEnv = await this.resolveGlobalEnv();
1050
- const probe = await probeConnection(this.toClientConfig(request.server, env, globalEnv), {
1070
+ const probeConfig = this.toClientConfig(request.server, env, globalEnv);
1071
+ // Test connection also supports OAuth: an OAuth-protected server
1072
+ // opens the browser for authorization and the probe continues after
1073
+ // tokens are stored (keyed by serverName, matching the mount path).
1074
+ // Static-credential servers (configured Authorization header) skip
1075
+ // the provider so a 401 probe reports the authentication failure
1076
+ // instead of starting an OAuth flow the server never asked for.
1077
+ const probe = await probeConnection(probeConfig, {
1051
1078
  timeoutMs: this.probeTimeoutMs,
1052
- authProvider: createOAuthProvider(request.server.serverName, this.ctx, { serverUrl: request.server.url })
1079
+ ...(this.wantsOAuthProvider(probeConfig)
1080
+ ? { authProvider: createOAuthProvider(request.server.serverName, this.ctx, { serverUrl: request.server.url }) }
1081
+ : {})
1053
1082
  });
1054
1083
  const elapsedMs = Date.now() - startedAt;
1055
1084
  return {
@@ -1106,12 +1135,12 @@ let McpManagerService = (() => {
1106
1135
  installToolControl() {
1107
1136
  this.ctx.tools.register({
1108
1137
  name: "mcp_tool_search",
1109
- description: "按关键词检索当前启用的 MCP 工具,返回匹配工具的名称/描述/参数 schema,并将命中的工具加入热注入集(下一轮模型请求即可直接调用)。需要某个 MCP 工具但不知道确切名称时使用。",
1138
+ description: hostText(this.ctx, "toolSearchDesc"),
1110
1139
  parameters: {
1111
1140
  type: "object",
1112
1141
  properties: {
1113
- query: { type: "string", description: "检索关键词,如 gitlab merge request、feishu 文档、hive 表等" },
1114
- limit: { type: "number", description: "最多返回条数,默认 8,最大 20" }
1142
+ query: { type: "string", description: hostText(this.ctx, "toolSearchQuery") },
1143
+ limit: { type: "number", description: hostText(this.ctx, "toolSearchLimit") }
1115
1144
  },
1116
1145
  required: ["query"],
1117
1146
  additionalProperties: false
@@ -1151,8 +1180,8 @@ let McpManagerService = (() => {
1151
1180
  content: [{
1152
1181
  type: "text",
1153
1182
  text: lines.length > 0
1154
- ? "匹配 MCP 工具(已热启用,下一轮可直接调用):\n" + lines.join("\n")
1155
- : "未找到匹配的 MCP 工具,可换关键词重试。"
1183
+ ? hostText(this.ctx, "searchHit", { lines: lines.join("\n") })
1184
+ : hostText(this.ctx, "searchMiss")
1156
1185
  }]
1157
1186
  };
1158
1187
  }
@@ -1178,10 +1207,20 @@ let McpManagerService = (() => {
1178
1207
  assembly.sections = (assembly.sections || []).filter((section) => section.name !== "mcp-tool-control");
1179
1208
  assembly.sections.push({
1180
1209
  name: "mcp-tool-control",
1181
- text: "MCP 工具按需可用:需要某个 MCP 工具时先调用 mcp_tool_search(query) 检索,命中后该工具会自动注入当前对话;当前可用 MCP 服务器:" + (servers.join(", ") || "(无)") + "。"
1210
+ text: hostText(this.ctx, "toolControlPrompt", { servers: servers.join(", ") || hostText(this.ctx, "noServers") })
1182
1211
  });
1183
1212
  }
1184
1213
  assembly.tools = kept;
1214
+ // Canonical tool order: render MCP tools sorted by name so the
1215
+ // system prompt stays byte-stable for the same tool set no
1216
+ // matter the registration or hot-set order (hot tools move on
1217
+ // every call, and servers may reorder tools/list) — stable
1218
+ // output is what preserves prompt-cache hits.
1219
+ const mcpKept = [];
1220
+ const otherKept = [];
1221
+ for (const tool of assembly.tools) (tool.name.startsWith("mcp__") ? mcpKept : otherKept).push(tool);
1222
+ mcpKept.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1223
+ assembly.tools = [...otherKept, ...mcpKept];
1185
1224
  return next();
1186
1225
  });
1187
1226
  this.ctx.on("tools/result", (exec) => {
package/lib/mcp-client.js CHANGED
@@ -10,7 +10,8 @@ import { isDeepStrictEqual } from "node:util";
10
10
  import { z as z$1 } from "zod";
11
11
  import { isImageAdmissionError } from "@deepseek-ai/dsh-attachment";
12
12
  import { assertSupportedJsonSchema } from "@deepseek-ai/dsh-tools";
13
- import { createToolAuthorizationUrl } from "./oauth.js";
13
+ import { createToolAuthorizationUrl, OAUTH_REQUIRED_CODE, oauthRequiredError } from "./oauth.js";
14
+ import { hostText } from "./host-locales.js";
14
15
  //#region lib/types/transport.js
15
16
  /**
16
17
  * Transport factory: creates the appropriate MCP transport based on the
@@ -205,9 +206,22 @@ function toolPublicEqual(a, b) {
205
206
  if (!jsonEqual(a.parameters, b.parameters)) return false;
206
207
  return jsonEqual(a.output && a.output.schema, b.output && b.output.schema);
207
208
  }
208
- /** Deep equality of JSON-serializable values (schema objects). */
209
+ /**
210
+ * Recursively sort object keys so semantically identical JSON compares equal
211
+ * regardless of key order (servers often reorder schema keys between lists).
212
+ */
213
+ function canonicalize(value) {
214
+ if (Array.isArray(value)) return value.map(canonicalize);
215
+ if (value !== null && typeof value === "object") {
216
+ const out = {};
217
+ for (const key of Object.keys(value).sort()) out[key] = canonicalize(value[key]);
218
+ return out;
219
+ }
220
+ return value;
221
+ }
222
+ /** Deep equality of JSON-serializable values, ignoring object key order. */
209
223
  function jsonEqual(a, b) {
210
- return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
224
+ return JSON.stringify(canonicalize(a ?? null)) === JSON.stringify(canonicalize(b ?? null));
211
225
  }
212
226
  /** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
213
227
  function supportedOutputSchema(candidate) {
@@ -225,7 +239,11 @@ function createDefinition(client, ctx, publicName, rawName, description, paramet
225
239
  return {
226
240
  name: publicName,
227
241
  description,
228
- parameters,
242
+ // Canonicalize before registration: servers often return JSON Schema
243
+ // with unstable key ordering; sorting keys keeps the model-visible tool
244
+ // list byte-stable across re-syncs and reconnects, which is what
245
+ // preserves prompt-cache hits.
246
+ parameters: canonicalize(parameters),
229
247
  output: createOutput(rawName, structuredSchema),
230
248
  execute: createExecutor(client, ctx, rawName, taskRequired, opts, projections),
231
249
  finalizeContent(exec, result) {
@@ -249,7 +267,7 @@ function createOutput(rawName, structuredSchema) {
249
267
  type: "array",
250
268
  items: {}
251
269
  },
252
- structuredContent: structuredSchema ?? {}
270
+ structuredContent: canonicalize(structuredSchema ?? {})
253
271
  },
254
272
  required: structuredSchema === void 0 ? ["content"] : ["content", "structuredContent"],
255
273
  additionalProperties: false
@@ -292,10 +310,15 @@ function createExecutor(client, ctx, rawName, taskRequired, opts, projections) {
292
310
  if (expired) {
293
311
  try {
294
312
  const authUrl = await createToolAuthorizationUrl(opts.serverName, ctx, opts.url);
295
- throw new Error(`服务器 "${opts.serverName}" 需要 OAuth 授权,请在浏览器打开以下链接完成授权后重试:\n${authUrl}`);
313
+ throw oauthRequiredError(hostText(ctx, "oauthToolLink", { serverName: opts.serverName, url: authUrl }));
296
314
  } catch (error) {
297
- if (error instanceof Error && error.message.includes("授权")) throw error;
298
- throw new Error(`服务器 "${opts.serverName}" 需要 OAuth 授权:请在 Settings → MCP 编辑该服务器并点击「测试连接」完成授权后重试`);
315
+ // The link-carrying error thrown just above is the actionable
316
+ // outcome; anything else (discovery/registration failure) is
317
+ // reported as the generic Settings → MCP fallback. Keyed on
318
+ // the stable error code so localized message text never
319
+ // drives control flow.
320
+ if (error instanceof Error && error.code === OAUTH_REQUIRED_CODE) throw error;
321
+ throw oauthRequiredError(new Error(hostText(ctx, "oauthToolFallback", { serverName: opts.serverName })));
299
322
  }
300
323
  }
301
324
  }
package/lib/oauth.js CHANGED
@@ -21,6 +21,25 @@ import {
21
21
  startAuthorization,
22
22
  } from "@modelcontextprotocol/sdk/client/auth.js";
23
23
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
24
+ import { hostText } from "./host-locales.js";
25
+
26
+ /**
27
+ * Error code marking an "OAuth authorization required" failure. Throwers set
28
+ * `error.code` to this value so control flow (re-throw decisions) keys on a
29
+ * stable code, never on localized message text.
30
+ */
31
+ export const OAUTH_REQUIRED_CODE = "MCP_OAUTH_REQUIRED";
32
+
33
+ /** Set the OAuth-required marker on an Error and return it. */
34
+ function oauthRequired(error) {
35
+ error.code = OAUTH_REQUIRED_CODE;
36
+ return error;
37
+ }
38
+
39
+ /** Build a localized "authorization required" Error carrying the stable code. */
40
+ export function oauthRequiredError(message) {
41
+ return oauthRequired(new Error(message));
42
+ }
24
43
 
25
44
  /** Credential reference namespace prefix for per-server OAuth tokens. */
26
45
  const OAUTH_REF_PREFIX = "DSH_MCP_OAUTH_";
@@ -253,7 +272,7 @@ export function createOAuthProvider(serverId, ctx, options = {}) {
253
272
  // fail with the clickable authorization link. The shared
254
273
  // listener keeps running so opening the URL completes the flow.
255
274
  log.info(`${tag}: mount requires authorization, not opening browser`);
256
- throw new Error(`服务器 "${serverId}" 需要 OAuth 授权,请在浏览器打开以下链接完成授权后重试(也可在 Settings → MCP 点击「测试连接」):\n${url}`);
275
+ throw oauthRequired(new Error(hostText(ctx, "oauthNeedLink", { serverId, url })));
257
276
  }
258
277
  log.info(`${tag}: opening browser for authorization: ${url}`);
259
278
  openBrowser(url);
@@ -273,12 +292,12 @@ export function createOAuthProvider(serverId, ctx, options = {}) {
273
292
  const error = url.searchParams.get("error");
274
293
  res.setHeader("content-type", "text/html; charset=utf-8");
275
294
  if (error) {
276
- res.end(`授权失败:${error},可关闭此页面并返回 DSH。`);
295
+ res.end(hostText(ctx, "oauthFailPage", { error }));
277
296
  log.error(`${tag}: callback rejected: ${error}`);
278
297
  resolveResult({ error });
279
298
  return;
280
299
  }
281
- res.end("授权成功,可关闭此页面并返回 DSH。");
300
+ res.end(hostText(ctx, "oauthOkPage"));
282
301
  log.info(`${tag}: authorization callback received (code)`);
283
302
  resolveResult({ code });
284
303
  });
@@ -291,7 +310,7 @@ export function createOAuthProvider(serverId, ctx, options = {}) {
291
310
  openBrowser(authorizationUrl.toString());
292
311
  try {
293
312
  const result = await resultPromise;
294
- if (result.error) throw new Error(`OAuth 授权失败:${result.error}`);
313
+ if (result.error) throw oauthRequired(new Error(hostText(ctx, "oauthExchangeFail", { error: result.error })));
295
314
  const authServerUrl = new URL(authorizationUrl.origin);
296
315
  log.info(`${tag}: discovering authorization server metadata at ${authServerUrl}`);
297
316
  const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
@@ -392,13 +411,13 @@ export async function createToolAuthorizationUrl(serverId, ctx, serverUrl) {
392
411
  const error = callbackUrl.searchParams.get("error");
393
412
  res.setHeader("content-type", "text/html; charset=utf-8");
394
413
  if (error) {
395
- res.end(`授权失败:${error},可关闭此页面并返回 DSH。`);
414
+ res.end(hostText(ctx, "oauthFailPage", { error }));
396
415
  log.error(`${tag}: callback rejected: ${error}`);
397
416
  resolve();
398
417
  server.close();
399
418
  return;
400
419
  }
401
- res.end("授权成功,可关闭此页面并返回 DSH。");
420
+ res.end(hostText(ctx, "oauthOkPage"));
402
421
  log.info(`${tag}: tool-auth callback received (code)`);
403
422
  try {
404
423
  const authServerUrl = new URL(authorizationUrl.origin);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "MCP 管理界面 + tool search:可视化配置/启停/刷新 MCP 服务器与工具勾选,按需检索(tool search)热注入;工具列表稳定命中缓存、不撑爆上下文。DeepSeek Harness plugin: MCP management UI + tool search — stable tool list, cache-friendly, no context bloat.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -22,7 +22,7 @@
22
22
  ],
23
23
  "scripts": {
24
24
  "build": "node scripts/build.mjs",
25
- "test": "node --test test/mcp-image-projection.test.mjs"
25
+ "test": "node --test test/mcp-image-projection.test.mjs test/host-locales.test.mjs"
26
26
  },
27
27
  "author": "Arvin.qi <arvin.qi@qq.com>",
28
28
  "license": "MIT",
@@ -37,7 +37,6 @@
37
37
  "dsh": {
38
38
  "client": {
39
39
  "inject": [
40
- "@deepseek-ai/dsh-client-runtime",
41
40
  "@deepseek-ai/dsh-api-remotes",
42
41
  "@deepseek-ai/dsh-client-ui-settings",
43
42
  "@deepseek-ai/dsh-client-locale"
package/scripts/build.mjs CHANGED
@@ -50,12 +50,9 @@ const EXTERNALS = [
50
50
  'react-dom',
51
51
  'react-dom/client',
52
52
  '@deepseek-ai/cordis',
53
+ '@deepseek-ai/dsh-client-store',
53
54
  '@deepseek-ai/dsh-client-ui-slots',
54
- '@deepseek-ai/dsh-client-web-react',
55
55
  '@deepseek-ai/dsh-client-ui-primitives',
56
- '@deepseek-ai/dsh-client-ui-attachment',
57
- '@deepseek-ai/dsh-client-schema-form',
58
- '@deepseek-ai/dsh-client-runtime/client',
59
56
  ]
60
57
 
61
58
  /** Locate the esbuild package inside a pnpm checkout (store or hoisted). */
@@ -79,6 +79,7 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
79
79
  const t = props.t
80
80
 
81
81
  const [loadErrorDetail, setLoadErrorDetail] = useState<string | null>(null)
82
+ const [hostMissing, setHostMissing] = useState(false)
82
83
  const [tools, setTools] = useState<McpToolsState | null>(null)
83
84
  const [toolsError, setToolsError] = useState<string | null>(null)
84
85
  const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set())
@@ -107,7 +108,12 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
107
108
  // The settings shell hides Remote failures behind a generic copy; surface
108
109
  // the real message here so a broken list() is diagnosable from the page.
109
110
  console.error('[dsh-mcp] list failed:', error)
110
- setLoadErrorDetail(String((error instanceof Error ? error.message : error) ?? error))
111
+ const message = String((error instanceof Error ? error.message : error) ?? error)
112
+ setLoadErrorDetail(message)
113
+ // HTTP 404 on /api/mcpManager/* means the host did not register the
114
+ // mcpManager Typert service (host half missing or client/host version
115
+ // mismatch). Surface that distinctively instead of a bare transport error.
116
+ setHostMissing(/HTTP 404|transport failure/.test(message))
111
117
  setLoadState('error')
112
118
  }
113
119
 
@@ -117,6 +123,7 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
117
123
  setServers(servers)
118
124
  setLoadState('ready')
119
125
  setLoadErrorDetail(null)
126
+ setHostMissing(false)
120
127
  },
121
128
  (error) => fail(error),
122
129
  )
@@ -131,6 +138,7 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
131
138
  setServers(servers)
132
139
  setLoadState('ready')
133
140
  setLoadErrorDetail(null)
141
+ setHostMissing(false)
134
142
  },
135
143
  (error) => { if (current) fail(error) },
136
144
  )
@@ -169,7 +177,7 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
169
177
  setRefreshing(prev => new Set(prev).add(serverName))
170
178
  void Promise.all([
171
179
  list().then(
172
- (servers) => { setServers(servers); setLoadState('ready') },
180
+ (servers) => { setServers(servers); setLoadState('ready'); setHostMissing(false) },
173
181
  () => {},
174
182
  ),
175
183
  props.toolsList().then(setTools, () => {}),
@@ -225,6 +233,7 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
225
233
  return (
226
234
  <div className={css.failure}>
227
235
  <p role="alert">{t('loadError')}</p>
236
+ {hostMissing ? <p className={css.muted}>{t('loadErrorHostMissing')}</p> : null}
228
237
  {loadErrorDetail !== null ? <pre className={css.muted}>{loadErrorDetail}</pre> : null}
229
238
  <button type="button" onClick={() => { setLoadState('loading'); load() }}>{t('retry')}</button>
230
239
  </div>
@@ -95,7 +95,7 @@ function failureOf(error: { code: string; message: string }): McpManagerFailure
95
95
  * itself so it needs no modification to any in-box package.
96
96
  * @param ctx - Client Cordis root.
97
97
  */
98
- export async function apply(ctx: ClientContext): Promise<void> {
98
+ export async function apply(ctx: Context): Promise<void> {
99
99
  await ctx.remote.$mount(remoteContribution)
100
100
 
101
101
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-mcp: dictionaries')
@@ -5,6 +5,7 @@ export const zh = {
5
5
  nav: 'MCP',
6
6
  loading: '正在读取 MCP 服务器…',
7
7
  loadError: '暂时无法读取 MCP 服务器。',
8
+ loadErrorHostMissing: '宿主端未提供 mcpManager 服务(HTTP 404)。通常是插件 host 半未注册或 client/host 版本不匹配:请确认 cordis.patch.yml 已注册 dsh-mcp 行、重启 dsh web 并硬刷新(Cmd/Ctrl+Shift+R);若升级过插件请同步升级宿主。',
8
9
  retry: '重试',
9
10
  empty: '还没有配置 MCP 服务器。',
10
11
  addServer: '添加服务器',
@@ -111,6 +112,7 @@ export const en = {
111
112
  nav: 'MCP',
112
113
  loading: 'Reading MCP servers…',
113
114
  loadError: 'MCP servers are temporarily unavailable.',
115
+ loadErrorHostMissing: 'The host does not expose the mcpManager service (HTTP 404). Usually the plugin host half is not registered or the client/host versions mismatch: confirm the cordis.patch.yml dsh-mcp row, restart dsh web, and hard-refresh (Cmd/Ctrl+Shift+R); after upgrading the plugin, upgrade the host to match.',
114
116
  retry: 'Retry',
115
117
  empty: 'No MCP servers are configured yet.',
116
118
  addServer: 'Add server',
@@ -4,7 +4,7 @@
4
4
  * inject callbacks; the store only mirrors their results so components stay
5
5
  * pure presentational.
6
6
  */
7
- import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
7
+ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
8
8
  import type {
9
9
  McpProbeView, McpServerId, McpServerInput, McpServerView, McpTransportKind,
10
10
  } from './types.ts'