@oh-agent/dsh-mcp-manager
中文 · English
一个设置页,管理散落在本机各个 AI 开发工具里的 MCP 服务器 —— 每个配置文件一张卡片,显示它声明了什么;每个服务器一个开关,把它挂载进 DeepSeek Harness;再加一次真连探活,看它到底能不能起来;更重要的是,页面直接告诉你它在 Harness 里到底加载成功没有,失败了可以就地重试。
入口:DeepSeek Harness → 设置 → MCP(位于「技能管理」和「插件市场」之间)。
安装
方式一:插件管理页(推荐)
设置 → 插件管理 → 安装,搜索 @oh-agent/dsh-mcp-manager。
方式二:让 agent 安装
直接说「安装 @oh-agent/dsh-mcp-manager」,等价于:
plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.6"
target 也可以填一个绝对目录 —— 本地源码目录,或者
v0.1.6 release 里附的
dsh-mcp-manager.tgz 解出来的目录。
不要手动 pnpm add 进 profile,也不要手改 profile 的 package.json 或
cordis.patch.yml:装了包不等于选中了它的 bundle,手动装的后果是那一行存在但永远不会被挂载。
安装后增加一个设置页和一个 agent 工具(mcp_manage)。它没有任何运行时依赖,不需要构建,也没有 allowBuilds 授权步骤。要求 Node ^22.19.0 || >=24.0.0(DeepSeek Harness 自带 Node 24,无需另装)。
页面会读取其他工具的配置文件,但开关不需要对它们有写权限。所有数据都不离开本机 —— 唯一的对外流量是你主动点的那次探活,且只发往你点的那一个服务器。
它解决什么问题
同一个 MCP 服务器,在每个工具里声明在不同的文件、不同的键、不同的方言下:
| 工具 | 文件(Windows) | 顶层键 |
|---|
| Claude Desktop | %APPDATA%\Claude\claude_desktop_config.json | mcpServers |
| Claude Code | ~/.claude.json、<proj>/.mcp.json | mcpServers |
| Cursor | ~/.cursor/mcp.json | mcpServers |
| Windsurf | ~/.codeium/windsurf/mcp_config.json | mcpServers |
| VS Code | <ws>/.vscode/mcp.json | servers |
| Roo Code | …\rooveterinaryinc.roo-cline\settings\cline_mcp_settings.json | mcpServers |
| Gemini CLI | ~/.gemini/settings.json | mcpServers |
| Qwen Code | ~/.qwen/settings.json | mcpServers |
| Qoder IDE | %APPDATA%\Qoder\SharedClientCache\mcp.json | mcpServers |
| OpenCode | ~/.config/opencode/opencode.json | mcp |
| Trae | %APPDATA%\Trae\User\mcp.json | mcpServers |
| Trae CN | %APPDATA%\Trae CN\User\mcp.json | mcpServers |
| Copilot CLI | ~/.copilot/mcp-config.json | mcpServers |
| Kiro | ~/.kiro/settings/mcp.json | mcpServers |
| Zed | settings.json | context_servers |
| Continue | ~/.continue/config.yaml | mcpServers(YAML 数组) |
| CodeBuddy | %APPDATA%\CodeBuddy\... | mcpServers |
| DeepSeek Harness | profile 组合文件 | @deepseek-ai/dsh-mcp-client 行 |
这些差异不是美观问题,而是手改最容易翻车的地方:
- 远程传输的拼写不同。 Windsurf 只认
serverUrl,写成 url 会被静默忽略。Gemini 和 Qwen 区分 url(SSE)与 httpUrl(streamable HTTP),而 Roo 把 streamable HTTP 拼成 streamable-http。
- 超时单位不同。 Gemini / Qwen / DSH 是毫秒,Roo 是秒。
- 命令的形态不同。 OpenCode 的
command 是数组,env 叫 environment;其他工具是字符串 command 加独立的 args。
- 有些文件混着无关状态。
~/.claude.json 里还有历史记录和 OAuth token;opencode.json 里有 provider 和 plugin。整文件重写会把这些一起毁掉。
本插件把这一切读入同一个模型,写回时只改它想改的那一条。
Windows 与 macOS
没有任何路径写死在单一平台上。每个适配器通过读取操作系统的解析器声明自己的文件:
| 根 | Windows | macOS |
|---|
appData() | %APPDATA% | ~/Library/Application Support |
xdgConfig() | ~/.config | ~/.config |
vscodeUserDir() | %APPDATA%\Code\User | ~/Library/Application Support/Code/User |
zedConfig() | %APPDATA%\Zed | ~/.config/zed |
HOME 相对 | ~/.codex、~/.cursor … | 相同 |
Zed 是值得点名的例外:macOS 上它读 ~/.config/zed,不是 Application Support,所以它有独立解析器。APPDATA、XDG_CONFIG_HOME、COPILOT_HOME 被设置时会生效。
项目级路径始终拼在工作区根目录上,天然可移植。scripts/test-paths.mjs 在模拟的 win32、darwin、linux 下加载真实的适配器源码,逐个客户端断言解析出的目录。
架构
client.js (设置页) index.js (宿主入口)
│ │
└──────── /api/dsh-mcp-manager ────┘
│ │
scan.js ops.js probe.js
│ │ │
adapters.js store.js surgical.js
│ │ │
canonical.js ledger+ 保字节编辑
(统一模型) 备份 JSON/TOML/YAML
│
dsh.js + dsh-patch.js + toggle.js
-
canonical.js —— 唯一的语义模型。 每个客户端条目都解码为 {name, transport, command, args, env, url, headers, timeoutMs, enabled, autoApprove, extra, secrets}。能力不向最小公约数对齐:某个客户端表达不了的字段,写入时丢弃并作为 droppedFields 上报,绝不悄悄编造。
-
adapters.js —— 唯一懂格式的层。 十八个客户端即数据:容器键、方言(哪个键表示 url、哪种 type 字符串映射到哪种传输、超时单位)、能力、文件位置。
-
surgical.js —— 保字节编辑器。 携带偏移量的解析树 + 自底向上的编辑,替换一条不会重排整个文件。注释、尾逗号、CRLF、无关的键都活下来;解析不了就拒绝,而不是重写。
-
store.js —— 安全网。 写入前字节备份、比较并交换的哈希校验、归属台账。
-
probe.js —— 它到底能不能用。 手写的 JSON-RPC 客户端(不依赖 @modelcontextprotocol/sdk):spawn stdio → initialize → notifications/initialized → tools/list;远程则 POST,带 Accept: application/json, text/event-stream 并跟随 SSE 响应。只读 —— 从不调用任何工具。
-
dsh.js / dsh-patch.js / toggle.js —— 把 Harness 自己也当成一等目标。 已存在的行通过 configEditor.edit() 改写(校验 + 热重载)。全新的服务器没有行可寻址,于是写进组合文件的 - insert: 补丁层,Loader 会自己把它拾起来 —— 操作会等这一行出现并上报 live: true。
如果预算内没等到,它上报 activating: true,绝不说「需要重启」:补丁行只会写在 Loader 看得见的地方,所以它迟早会生效。给一个正在生效中的服务器提示重启是实测出来的错误 —— 连续挂载多个服务器会把重排推过任何固定预算,随后它们全部以 live 出现。
发现逻辑读的是 entry.options.name,不是 entry.name:运行中的 Loader 条目自身没有 name 属性(已在运行中的 Host 上验证过),读错字段会静默匹配到零行 —— 结果每个已挂载的服务器都看起来没生效,并索要一次根本不需要的重启。
补丁行写进哪个 profile 只认正在跑的那个 Host:profileContext(boot() 在任何 profile 插件加载之前 provide,含 { name, dir })排第一,DSH_PROFILE_DIR、--profile 参数、DSH_PROFILE 依次兜底,机器上恰好只有一个 profile 时才用「唯一」这个事实,否则报 error.dsh.noProfile 拒绝写。不能只靠 DSH_PROFILE_DIR:dsh-shell-env 只把它注入 shell 工具的执行环境,从不注入 Host 自己的进程 —— 桌面版就是这种情况,加上本机有 5 个 profile,旧逻辑必然解析失败,添加服务器时报 cannot locate the active DSH profile。反向也不行:早先退回 ~/.dsh/profiles/desktop 会写进一个根本没在跑的 profile,行是真的,Loader 永远看不见。
安全模型
写入路径围绕一条规则构建:只重写我们能证明是自己写的东西。
- 归属台账。 每次写入记录
{path, serverName, fingerprint}。之后同名写入只有在磁盘上的定义仍与该指纹一致(用户没改过)时才生效。
- 绝不覆盖别人的服务器。 名字已被非我们写入的条目占用时,视为用户的:新来的服务器改用带后缀的名字。
- 乐观并发。 调用方传入它扫描到的哈希;文件变了就返回
409 和当前哈希,界面提示重新扫描。不存在静默的后写覆盖。
- 每次修改前备份,按
backupsToKeep 保留,可在页面还原。
- 原子替换。 写临时文件、
rename 覆盖目标、再读回并重新解析,证明结果是合法的。
- 凭据在离开宿主前就被掩码。 掩码是按键驱动的,不是按值形状 —— 短 token(
TOKEN=abc)照样掩码,而间接引用(${FIGMA_API_KEY})原样显示,因为它指向密钥名而不是携带密钥。覆盖字面值所有可能藏身的地方:env、headers、未建模的 extra、原始条目副本,以及命令行参数(--figma-api-key=figd_…,这是好几个服务器的官方调用方式,根本不在 env 里)。reveal 是需要显式确认的动作。
- 项目作用域拒绝字面凭据。 往要进仓库的文件里写明文密钥会失败,并提示改用环境变量引用。
- 被掩码的定义永远写不回去。 浏览器手里只有脱敏副本,所以每次写入都从宿主侧的扫描结果解析定义;万一
figd••••0000 还是流到了写入路径,会以 error.server.masked 拒绝,而不是把真实凭据换成圆点。
- 变更请求需要自定义头(
x-dsh-mcp-manager: 1),叠加在 JSON content-type 和 loopback 来源校验之上 —— 跨站表单设不了这个头。
开关只把服务器挂进 DeepSeek Harness,别的什么都不动
每行上的唯一控件是一个开关,含义只有一条:这个服务器有没有挂进 DeepSeek Harness?
打开就在 Harness 里挂载,关闭就卸载。定义来自哪个文件,开关从不以写入方式打开 —— 通过在本机对一次挂载/卸载循环前后哈希每个客户端配置来验证,全部未变。
这个限制不是洁癖,是格式逼出来的:大多数客户端根本没有 enable 字段。Qoder 和 Trae 的 mcp.json 里没有任何 enable 之类的键。写一个进去,就是一个会撒谎的开关。
按情形说明挂载怎么实现:
| 服务器是 | 开关做的事 | 生效时机 |
|---|
已经是运行中的 dsh-mcp-client 行 | pluginManager.setPluginEnabled(rowId, …) | 立即 —— 行卸载,其工具从工具列表消失 |
我们自己写的 - insert: 项 | 关:删掉该项;开:重写它 | 重排后生效;文件回到精确的原始字节 |
用户自己写的 - insert: 项 | 追加/移除插件管理器自己也会写的那种 - id: … / disabled: true 覆盖项 | 重排后生效 |
| 只声明在别的工具里,且被打开 | 写一条新的 - insert: 行 | Loader 重排后即生效(通常 1–4 秒);操作最多等 8 秒并上报 live: true,否则 activating: true |
| 哪里都没挂载,且被关闭 | 什么都不做 —— 本就是事实,不留悬空覆盖项 | — |
顺序很重要,这是踩过的坑:Loader 路径必须排在改文件之前。上面第一行和下面几行会同时命中 —— 我们自己写过的 - insert: 项,一旦被 Loader 读进来就是一条活的 entry。早先的版本先判「这行是我们的就删文件」,结果关一次开关,删掉的是 YAML 行(要重排才生效),而不是热卸载那个正在跑的实例;更糟的是重新打开时该块会被追加到文件末尾,用户精心排过序的 cordis.patch.yml 被搬了家。实测过一次:15640 字节的文件在开关一轮之后 SHA 变了、mcp-playwright 整块从前面挪到了文件尾。现在先走 Loader,只有在根本不存在这个 entry 时才回落到改文件,scripts/test-toggle.mjs 用「开关一轮之后补丁文件字节零变化」钉住了这件事。
我们自己写的行会在尝试 Loader 路径之前被移除:插件管理器确实能热卸载一行,但它的做法是在定义旁边追加一条 disabled: true 覆盖项,那会让我们的行和这条覆盖项一起永久留在用户的组合文件里。删除 insert 项既是卸载也是清理。
因为覆盖项是独立的一项而不是重写,且我们自己写的行是删除而不是禁用,一次完整的挂载 → 卸载循环会把 cordis.patch.yml 还原到精确的原始字节。
挂上了 ≠ 加载成功了
这是最容易撒谎的一格。行存在于 cordis.patch.yml 里,只证明它被挂载了;它一点都不能证明 npx 起没起来、握手成没成功。
@deepseek-ai/dsh-mcp-client 的默认 failOnStartupError: false 让这件事更隐蔽:apply() 会等 connection.ready,连接失败时只是返回,不抛错。于是插件管理页里那一行的 fiberPhase 照样是 active —— 你看到的每一项都是绿的,工具却一个都没有。把 Node 切到 14 再启动 DSH 就是这个现象:老 Node 跑不了新语法的 MCP 服务器,进程立刻退出,界面上却什么异常都没有。
所以本插件不看 fiberPhase,改看工具注册表:一个连接成功的 MCP 服务器,必然在 tools 里注册了 mcp__<serverName>__<工具名> 形式的工具。有就是 loaded,一条都没有就是 failed,这才是真话。
| 页面显示 | 含义 |
|---|
已加载 · N 个工具 | 连接成功,工具已注册,agent 现在就能调 |
已挂载但加载失败 + 「重试」 | 行挂上了,但一个工具都没注册 —— 进程没起来或握手失败 |
| 页面顶部红色横幅 + 「全部重试」 | 有 N 个服务器挂上了却连不上,一次把失败的全部重试 |
正在激活 | 刚写完补丁行,Loader 还没重排完 |
加载状态未知 | 读不到工具注册表(老版本宿主),此时不下结论,绝不误判成失败 |
拿不到注册表时宁可显示「未知」也不显示「失败」:把一台健康的服务器标成坏的,比不标更糟。同理,红色横幅只在注册表可读时才出现 —— 否则每一行都是「未知」,那时候报「失败」纯属猜测。
失败行的「重试」不是 hmr.reconcileProfile。那个看着像正解,实际是个假动作:它只重启处于 inactive 的行,而 @deepseek-ai/dsh-mcp-client 在 failOnStartupError: false 下会把连接错误吞掉,fiber 一直是 active。拿一条死行实测过:reconcileProfile 返回成功,一个连接都没重建 —— 一个会说谎的重试按钮。
真正的杠杆是 pluginManager.setPluginEnabled(entryId, false) 再 (entryId, true):先 dispose 掉那条死 fiber,再重新走一遍完整的挂载。据此分三步:
setPluginEnabled(entryId, false) 拆掉旧连接 —— 这一步失败就直接报「could not dispose the stale connection: 」,不要假装重试过;
setPluginEnabled(entryId, true) 重建 —— 抛异常时尽力恢复成「开着」,宁可保留一个连不上的行,也不能把用户的服务悄悄关掉;
- 轮询工具注册表(
mcp__<挂载名>__*)直到出现工具或超时。判定依据是注册表,不是组合文件:重建后的 fiber 会立刻出现在组合里,可只有它真注册上工具才算连上了。
为什么轮询注册表而不是直接看 fiber:fiber active 只说明「这个插件被加载了」,连不上也照样 active,这正是当初「显示已挂载、其实加载失败」的根源。重连不是瞬时的(起进程 + 握手),所以按钮点完 2.5 秒后页面会自己再读一次状态;等不到工具时提示里带的是宿主返回的真实原因,而不是一句「出错了」。
scripts/test-load.mjs 钉住了判定边界(unknown / failed / loaded 三者不能互相冒充,按名字归属工具也不能张冠李戴),scripts/test-retry.mjs 钉住了重试本身:必须是「先关后开」两次调用、失败原因要原样透出、重建失败时不能把行留在关闭状态。
挂载名不总是显示名
@deepseek-ai/dsh-mcp-client 要求 serverName 匹配 /^[A-Za-z0-9_-]{1,32}$/。现实中很多服务器不满足 —— 大量中文名形如 示例服务器 - API 文档,也有带空格的英文名形如 My Bridge。只用 sanitizeName 压一遍,所有中文名都会变成 API,第二个挂载就会和第一个撞名,开关就会翻错服务器。
因此 canonical.js 导出 dshMountName(name):先 sanitize,如果结果丢了信息(中日韩字符、空格、超长),就追加原名的短哈希 —— 示例服务器 - API 文档 → API-0aac0ee2,My Bridge → My-Bridge。它是纯函数且全域的,所以写入路径、扫描、开关、移除都从同一输入算出同一字符串,不需要在任何地方存映射表。
扫描和页面处理的后果:
- 节点保留显示名;
mountedAs 携带行名,entryId 携带 Loader id,开关按映射后的名字找到自己的行;
- 服务器出现在 DSH 那张文件卡片里时用显示名,其他工具的卡片不变;
- 两个 sanitize 后同名基的服务器仍得到不同的行名(哈希不同),不会有服务器悄悄替换另一个。
scripts/test-mount-name.mjs 钉住了这个映射,包括撞名情形和经由 scan 的往返。
怎么用
页面布局和它旁边的技能页一致:标题行加操作按钮,五格汇总条(服务器 · 文件 · 已挂载进 DSH · 已加载 · 字面凭据),筛选行,然后每个配置文件一张带边框的卡片。
每张卡片列出该文件声明的服务器 —— 头部是工具名、服务器数量和路径(缩短成 %APPDATA%\… 风格,完整路径在 tooltip 里)。卡片可折叠;筛选生效时所有卡片都会展开,因为藏在折叠卡片里的命中会被读成「没有结果」。每行显示传输方式、命令或 URL、加载状态、有多少工具共用这份定义、探活结果,以及挂载开关。
筛选是一个自由文本搜索(名字、命令、路径)加两个下拉:按工具、按状态(已挂载 / 未挂载 / 已挂载但加载失败)。
点击一行打开对话框,而不是在原地展开 —— 原地展开会把它下面每一行都重排,让一张卡片看起来像两张叠在一起。对话框列出声明该服务器的每个文件、统一后的定义、一条可直接粘贴进 cordis 组合的 YAML 行,以及「复制到…」用于把定义推到别的工具。
标题行第一个按钮是「添加服务器」:把现成的 JSON 直接粘进去就行(Trae / Cursor / Claude 那种 {"mcpServers":{…}}),选好写入到哪个工具、哪个作用域即可。同样接受 VS Code 的 servers、OpenCode 的 mcp、Zed 的 context_servers 包装,也接受一个「名称 → 条目」映射或带 name 的单个条目;一次粘多个就一次写入多个。
写回用的是读这些文件时的同一套适配器,所以粘过来的条目会按目标工具的方言重写:选 Cursor 就是 Cursor 的 mcpServers,选 OpenCode 就写 command 数组、environment 和 local/remote,选 Windsurf 就把远程地址写成 serverUrl,选 DeepSeek Harness 就写 profile 补丁行并等 Loader 激活。目标工具还没有配置文件时会新建;同名条目拒绝写入并在对话框里列出原因,其余条目照常写入 —— 不会悄悄改名成 name-2,也不会覆盖已有定义。
同一批操作也以 mcp_manage 工具暴露给 agent,所以页面和 agent 共用一份实现,不可能走岔: