dsh-mcp 1.0.0 → 1.1.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 +6 -0
- package/CHANGELOG.md +6 -0
- package/README.en.md +61 -6
- package/README.md +58 -5
- package/lib/index.js +1 -1
- package/lib/mcp-client.js +727 -0
- package/package.json +2 -2
- package/static/snapshot.en.webp +0 -0
package/CHANGELOG.en.md
CHANGED
|
@@ -7,6 +7,12 @@ 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.1.0] - 2026-08-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Tool-list stabilization**: during a same-connection re-sync (e.g. a `tools/list_changed` notification), unchanged MCP tools keep their existing registration instead of being disposed and re-registered, keeping the system-prompt tool list stable to preserve prompt-cache hits (vendored `lib/mcp-client.js` extension)
|
|
15
|
+
|
|
10
16
|
## [1.0.0] - 2026-08-15
|
|
11
17
|
|
|
12
18
|
First stable release.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
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.1.0] - 2026-08-15
|
|
11
|
+
|
|
12
|
+
### 新增
|
|
13
|
+
|
|
14
|
+
- **工具列表稳定化**:同一连接的 re-sync(如 `tools/list_changed` 通知)时,未变化的 MCP 工具保留原注册,不再反复注销/重注册,保持系统提示词工具列表稳定以提升 prompt cache 命中率(vendored `lib/mcp-client.js` 扩展)
|
|
15
|
+
|
|
10
16
|
## [1.0.0] - 2026-08-15
|
|
11
17
|
|
|
12
18
|
首个正式版本。
|
package/README.en.md
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# dsh-mcp — MCP server management plugin for DeepSeek Harness (standalone)
|
|
2
2
|
|
|
3
|
-
](https://dshfind.com/en/plugins/ArvinQi/dsh-mcp?ref=badge)
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Why dsh-mcp?
|
|
8
|
+
|
|
9
|
+
**Problems it solves:**
|
|
10
|
+
|
|
11
|
+
- **Full tool injection burns tokens**: with multiple MCP servers, the tool count can reach hundreds, and injecting all of them every request is expensive. The `search` mode lets the model hot-inject only the tools it needs via `mcp_tool_search`, saving tokens at scale.
|
|
12
|
+
- **Re-syncs churn the tool list and break caches**: `tools/list_changed` notifications dispose and re-register same-named tools, jittering the system-prompt tool list and constantly invalidating the prompt cache. Tool-list stabilization keeps unchanged tools registered, maximizing cache hits.
|
|
13
|
+
- **No visual management entry**: server config, enable/disable, and tool toggles used to require editing files by hand. Settings → MCP brings everything into one UI.
|
|
14
|
+
|
|
15
|
+
**Highlights:**
|
|
16
|
+
|
|
17
|
+
- **Visual management**: server list / create / edit / delete / test connection / enable-disable / refresh, all in the UI
|
|
18
|
+
- **Fine-grained tool control**: expand each server to see its tools, all checked by default; uncheck to load only what you need
|
|
19
|
+
- **Two injection modes**: `search` (on-demand, token-saving) and `full` (inject everything)
|
|
20
|
+
- **Zero npm dependencies**: plugs into DeepSeek Harness internals, install and go
|
|
21
|
+
- **Three install paths**: npm / GitHub git source / local link; bilingual UI and docs
|
|
4
22
|
|
|
5
23
|
Migrated and merged from uncommitted MCP work in the `deepseek-harness` repository:
|
|
6
24
|
|
|
@@ -33,6 +51,7 @@ dsh-mcp/
|
|
|
33
51
|
├── package.json name=dsh-mcp; dsh.client declaration; zero npm dependencies
|
|
34
52
|
├── lib/
|
|
35
53
|
│ ├── index.js host half (McpManagerService, built from mcp-manager)
|
|
54
|
+
│ ├── mcp-client.js vendored MCP client (from @deepseek-ai/dsh-mcp-client, with tool-list stability extension)
|
|
36
55
|
│ ├── probe.js vendored connection probe (from mcp-client/src/probe.ts)
|
|
37
56
|
│ ├── transport.js vendored transport factory (from mcp-client/src/transport.ts)
|
|
38
57
|
│ └── client.js browser half (esbuild bundle, ModuleLoader wire format)
|
|
@@ -53,15 +72,17 @@ node scripts/build.mjs
|
|
|
53
72
|
- CSS Modules are handled by an esbuild onLoad plugin: styles are injected into a
|
|
54
73
|
`<style data-plugin="dsh-mcp" data-file="…">` tag, and the module default-exports an identity class-name map.
|
|
55
74
|
|
|
56
|
-
## Install
|
|
75
|
+
## Install & Usage
|
|
76
|
+
|
|
77
|
+
### 1. Install
|
|
57
78
|
|
|
58
|
-
|
|
79
|
+
**Option 1: npm (after publishing)**
|
|
59
80
|
|
|
60
81
|
```sh
|
|
61
82
|
dsh plugin --profile web add dsh-mcp
|
|
62
83
|
```
|
|
63
84
|
|
|
64
|
-
|
|
85
|
+
**Option 2: GitHub git source**
|
|
65
86
|
|
|
66
87
|
```sh
|
|
67
88
|
dsh plugin --profile web add github:ArvinQi/dsh-mcp
|
|
@@ -69,7 +90,7 @@ dsh plugin --profile web add github:ArvinQi/dsh-mcp
|
|
|
69
90
|
dsh plugin --profile web add git+https://github.com/ArvinQi/dsh-mcp.git
|
|
70
91
|
```
|
|
71
92
|
|
|
72
|
-
|
|
93
|
+
**Option 3: local development (link)**
|
|
73
94
|
|
|
74
95
|
```sh
|
|
75
96
|
dsh plugin --profile web add link:<absolute path to this repo>
|
|
@@ -79,7 +100,7 @@ dsh plugin --profile web add link:<absolute path to this repo>
|
|
|
79
100
|
> symlink (development-only, not committed); otherwise the linked symlink is realpath-resolved and `@deepseek-ai/*`
|
|
80
101
|
> cannot be resolved.
|
|
81
102
|
|
|
82
|
-
### Registration (all install options)
|
|
103
|
+
### 2. Registration (all install options)
|
|
83
104
|
|
|
84
105
|
Append to `$DSH_HOME/profiles/web/cordis.patch.yml` (`$DSH_HOME` defaults to `~/.dsh`):
|
|
85
106
|
|
|
@@ -92,6 +113,38 @@ Append to `$DSH_HOME/profiles/web/cordis.patch.yml` (`$DSH_HOME` defaults to `~/
|
|
|
92
113
|
Then **restart `dsh web`** (client roster changes require a restart); afterwards hard-refresh the browser
|
|
93
114
|
(`Cmd/Ctrl + Shift + R`) to load the settings page.
|
|
94
115
|
|
|
116
|
+
### 3. Usage
|
|
117
|
+
|
|
118
|
+
**Open the management page**: after restart, open DSH Web → **Settings → MCP**.
|
|
119
|
+
|
|
120
|
+
**Add a server**:
|
|
121
|
+
|
|
122
|
+
1. Click **Add server**.
|
|
123
|
+
2. Fill in: server name (`serverName`, determines the tool prefix `mcp__<serverName>__`), transport
|
|
124
|
+
(`streamable-http` → URL / `stdio` → command), headers, tool-call timeout, etc.
|
|
125
|
+
3. Click **Test connection** to verify connectivity and the tool list, then **Save**.
|
|
126
|
+
|
|
127
|
+
**Day-to-day management**:
|
|
128
|
+
|
|
129
|
+
- **Enable / disable**: row button; disabling a server unregisters all of its tools immediately.
|
|
130
|
+
- **Refresh**: re-pulls server status and the tool list (syncs new tools after a server restart).
|
|
131
|
+
- **Test connection**: available in the edit form at any time.
|
|
132
|
+
|
|
133
|
+
**Tool control (the key to saving tokens)**:
|
|
134
|
+
|
|
135
|
+
- **Injection mode**: switch at the top of the page between `search` (on-demand, default) and `full` (inject everything).
|
|
136
|
+
- In `search` mode, the model calls `mcp_tool_search` to discover and hot-inject the MCP tools it needs.
|
|
137
|
+
- **Tool toggles**: click **Expand tools** to see all tools of that server (all checked by default);
|
|
138
|
+
unchecking a tool keeps it out of injection, applied immediately without saving.
|
|
139
|
+
|
|
140
|
+
**Verifying the effect**:
|
|
141
|
+
|
|
142
|
+
- In any agent session, available tools include `mcp__<serverName>__<tool>`.
|
|
143
|
+
- In `search` mode, tools not retrieved stay out of the system prompt, saving tokens and improving
|
|
144
|
+
prompt-cache hit rate.
|
|
145
|
+
- When tool content is unchanged, `list_changed` notifications no longer dispose and re-register
|
|
146
|
+
same-named tools, keeping the tool list stable.
|
|
147
|
+
|
|
95
148
|
## Versioning notes
|
|
96
149
|
|
|
97
150
|
- The host half `lib/index.js` is a **build artifact** of mcp-manager (spec/types inlined); edit the lib files
|
|
@@ -104,3 +157,5 @@ Then **restart `dsh web`** (client roster changes require a restart); afterwards
|
|
|
104
157
|
## Changelog
|
|
105
158
|
|
|
106
159
|
See [CHANGELOG.md](CHANGELOG.md). Released under the [MIT License](LICENSE).
|
|
160
|
+
|
|
161
|
+
[](https://dshfind.com/en/plugins/ArvinQi/dsh-mcp?ref=badge)
|
package/README.md
CHANGED
|
@@ -2,8 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
**[English](README.en.md) | 简体中文**
|
|
4
4
|
|
|
5
|
+
[](https://dshfind.com/zh/plugins/ArvinQi/dsh-mcp?ref=badge)
|
|
6
|
+
|
|
5
7
|

|
|
6
8
|
|
|
9
|
+
## 为什么用 dsh-mcp?
|
|
10
|
+
|
|
11
|
+
**解决的核心问题:**
|
|
12
|
+
|
|
13
|
+
- **MCP 工具全量注入烧 token**:接入多个 MCP 服务器后工具可达上百个,每轮全量注入开销巨大。`search` 按需检索模式让模型通过 `mcp_tool_search` 热注入所需工具,大幅节省 token。
|
|
14
|
+
- **工具列表反复更新破坏缓存**:`tools/list_changed` 通知会让同名工具被反复注销/重注册,系统提示词工具列表抖动、prompt cache 频繁失效。工具列表稳定化让未变化的工具保留原注册,最大化 cache 命中。
|
|
15
|
+
- **没有可视化管理入口**:服务器配置、启停、工具勾选全靠手工改文件。Settings → MCP 一站式可视化完成。
|
|
16
|
+
|
|
17
|
+
**功能优势:**
|
|
18
|
+
|
|
19
|
+
- **可视化管理**:服务器列表 / 新建 / 编辑 / 删除 / 测试连接 / 启停 / 刷新,全 UI 操作
|
|
20
|
+
- **工具级精细控制**:每个服务器展开工具列表,默认全选,可取消勾选只加载需要的部分
|
|
21
|
+
- **双注入模式**:`search`(按需检索,省 token)与 `full`(全量注入)
|
|
22
|
+
- **零 npm 依赖**:直接对接 DeepSeek Harness 内部能力,安装即用
|
|
23
|
+
- **三种安装方式**:npm / GitHub git 源 / 本地 link;中英文界面与文档
|
|
24
|
+
|
|
7
25
|
## 功能
|
|
8
26
|
|
|
9
27
|
- **托管 MCP 服务器注册表**(host):持久化定义(storage-domain `mcp_servers`)、按服务器挂载
|
|
@@ -20,6 +38,7 @@ dsh-mcp/
|
|
|
20
38
|
├── package.json name=dsh-mcp;dsh.client 声明;零 npm dependencies
|
|
21
39
|
├── lib/
|
|
22
40
|
│ ├── index.js host 半部(McpManagerService,源自 mcp-manager 构建产物)
|
|
41
|
+
│ ├── mcp-client.js vendored MCP 客户端(源自 @deepseek-ai/dsh-mcp-client,含工具列表稳定扩展)
|
|
23
42
|
│ ├── probe.js vendored 连接探测(源自 mcp-client/src/probe.ts)
|
|
24
43
|
│ ├── transport.js vendored 传输工厂(源自 mcp-client/src/transport.ts)
|
|
25
44
|
│ └── client.js 浏览器半部(esbuild 打包,ModuleLoader wire format)
|
|
@@ -41,15 +60,17 @@ node scripts/build.mjs
|
|
|
41
60
|
- CSS Modules 由 esbuild onLoad 插件处理:样式注入
|
|
42
61
|
`<style data-plugin="dsh-mcp" data-file="…">`,默认导出 identity 类名映射。
|
|
43
62
|
|
|
44
|
-
##
|
|
63
|
+
## 安装使用
|
|
64
|
+
|
|
65
|
+
### 1. 安装
|
|
45
66
|
|
|
46
|
-
|
|
67
|
+
**方式一:npm(发布到 npm 后)**
|
|
47
68
|
|
|
48
69
|
```sh
|
|
49
70
|
dsh plugin --profile web add dsh-mcp
|
|
50
71
|
```
|
|
51
72
|
|
|
52
|
-
|
|
73
|
+
**方式二:GitHub git 源**
|
|
53
74
|
|
|
54
75
|
```sh
|
|
55
76
|
dsh plugin --profile web add github:ArvinQi/dsh-mcp
|
|
@@ -57,7 +78,7 @@ dsh plugin --profile web add github:ArvinQi/dsh-mcp
|
|
|
57
78
|
dsh plugin --profile web add git+https://github.com/ArvinQi/dsh-mcp.git
|
|
58
79
|
```
|
|
59
80
|
|
|
60
|
-
|
|
81
|
+
**方式三:本地开发(link)**
|
|
61
82
|
|
|
62
83
|
```sh
|
|
63
84
|
dsh plugin --profile web add link:<本仓库绝对路径>
|
|
@@ -67,7 +88,7 @@ dsh plugin --profile web add link:<本仓库绝对路径>
|
|
|
67
88
|
> symlink(本机开发用,不入库),否则 `link:` 安装的 symlink 被 realpath 后无法解析
|
|
68
89
|
> `@deepseek-ai/*`。
|
|
69
90
|
|
|
70
|
-
### 注册与生效(三种方式通用)
|
|
91
|
+
### 2. 注册与生效(三种方式通用)
|
|
71
92
|
|
|
72
93
|
在 `$DSH_HOME/profiles/web/cordis.patch.yml`(`$DSH_HOME` 默认 `~/.dsh`)追加:
|
|
73
94
|
|
|
@@ -80,6 +101,36 @@ dsh plugin --profile web add link:<本仓库绝对路径>
|
|
|
80
101
|
然后**重启 `dsh web`**(client roster 变更需重启);之后浏览器硬刷新(`Cmd/Ctrl + Shift + R`)
|
|
81
102
|
加载设置页。
|
|
82
103
|
|
|
104
|
+
### 3. 使用
|
|
105
|
+
|
|
106
|
+
**打开管理页**:重启后浏览器打开 DSH Web → **设置(Settings)→ MCP**。
|
|
107
|
+
|
|
108
|
+
**添加服务器**:
|
|
109
|
+
|
|
110
|
+
1. 点击「添加服务器」
|
|
111
|
+
2. 填写:服务器名称(`serverName`,决定工具前缀 `mcp__<serverName>__`)、传输方式
|
|
112
|
+
(`streamable-http` 填 URL / `stdio` 填命令)、请求头、工具调用超时等
|
|
113
|
+
3. 点「测试连接」确认连通性与工具列表,点「保存」
|
|
114
|
+
|
|
115
|
+
**日常管理**:
|
|
116
|
+
|
|
117
|
+
- **启用 / 禁用**:列表行按钮,禁用后该服务器所有工具即时注销,不再注入
|
|
118
|
+
- **刷新**:重新拉取服务器状态与工具列表(服务器重启后可同步新工具)
|
|
119
|
+
- **测试连接**:编辑页可随时测试
|
|
120
|
+
|
|
121
|
+
**工具控制(省 token 的关键)**:
|
|
122
|
+
|
|
123
|
+
- **注入模式**:页面顶部切换 `search`(按需检索,默认)或 `full`(全量注入)
|
|
124
|
+
- `search` 模式下,模型需要某 MCP 工具时调用 `mcp_tool_search` 检索并热注入当前对话
|
|
125
|
+
- **工具勾选**:点「展开工具」查看该服务器全部工具(默认全选),取消勾选 = 不注入该工具,
|
|
126
|
+
即时生效,无需保存
|
|
127
|
+
|
|
128
|
+
**验证效果**:
|
|
129
|
+
|
|
130
|
+
- 在任意 agent 会话中,可用工具应包含 `mcp__<服务器名>__<工具名>`
|
|
131
|
+
- `search` 模式下未检索到的工具不占系统提示词,节省 token 并提升 prompt cache 命中率
|
|
132
|
+
- 工具内容未变化时,`list_changed` 通知不会反复注销/重注册同名工具,工具列表保持稳定
|
|
133
|
+
|
|
83
134
|
## 版本注意
|
|
84
135
|
|
|
85
136
|
- host 半部 `lib/index.js` 是 mcp-manager 的**构建产物**(spec/types 已内联),改动请直接编辑
|
|
@@ -87,3 +138,5 @@ dsh plugin --profile web add link:<本仓库绝对路径>
|
|
|
87
138
|
- 浏览器半部改 `src/client/*` 后重新 `node scripts/build.mjs`;host 半部改动无需重装
|
|
88
139
|
(link 安装直接生效)。
|
|
89
140
|
- 配置变更(bundles 增删、新插件行)需重启 `dsh web` 才进入 client roster。
|
|
141
|
+
|
|
142
|
+
[](https://dshfind.com/zh/plugins/ArvinQi/dsh-mcp?ref=badge)
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { Service } from "@deepseek-ai/cordis";
|
|
3
3
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
4
|
-
import * as mcpClient from "
|
|
4
|
+
import * as mcpClient from "./mcp-client.js";
|
|
5
5
|
import { probeConnection } from "./probe.js";
|
|
6
6
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
7
7
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { ListToolsResultSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
6
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
7
|
+
import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { z as z$1 } from "zod";
|
|
10
|
+
import { assertSupportedJsonSchema } from "@deepseek-ai/dsh-tools";
|
|
11
|
+
//#region lib/types/transport.js
|
|
12
|
+
/**
|
|
13
|
+
* Transport factory: creates the appropriate MCP transport based on the
|
|
14
|
+
* plugin's resolved config. Stdio spawns a child process (with credential
|
|
15
|
+
* scrubbing); Streamable HTTP connects to a URL.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The subprocess seam's scrubbed parent env (credential-shaped and stale
|
|
21
|
+
* `DSH_*` names dropped), plus the spec's explicit env. The MCP SDK owns the
|
|
22
|
+
* actual spawn, so this transport shares the scrub definition rather than the
|
|
23
|
+
* spawn path.
|
|
24
|
+
*/
|
|
25
|
+
function buildChildEnv(extra) {
|
|
26
|
+
return {
|
|
27
|
+
...scrubbedParentEnv(),
|
|
28
|
+
...extra
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Create an MCP transport from the resolved plugin config.
|
|
33
|
+
*
|
|
34
|
+
* @param config - Resolved plugin config discriminated on `transport`.
|
|
35
|
+
* @returns A connected-ready MCP Transport (stdio or Streamable HTTP).
|
|
36
|
+
*/
|
|
37
|
+
function createTransport(config) {
|
|
38
|
+
switch (config.transport) {
|
|
39
|
+
case "stdio": return new StdioClientTransport({
|
|
40
|
+
command: config.command,
|
|
41
|
+
args: config.args,
|
|
42
|
+
env: buildChildEnv(config.env),
|
|
43
|
+
cwd: config.cwd
|
|
44
|
+
});
|
|
45
|
+
case "streamable-http": return new StreamableHTTPClientTransport(new URL(config.url), { requestInit: { headers: config.headers } });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region lib/types/tools.js
|
|
50
|
+
/**
|
|
51
|
+
* Tool bridge: discovers MCP tools, registers them on the harness ToolRuntime
|
|
52
|
+
* under deterministic server-qualified public names, and handles re-sync when
|
|
53
|
+
* the server's tool list changes.
|
|
54
|
+
*
|
|
55
|
+
* Naming contract (see the mcp-client Agent Note "Naming invariants"): every MCP tool
|
|
56
|
+
* has the stable identity `(serverName, rawName)`; the model-facing public name
|
|
57
|
+
* is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name
|
|
58
|
+
* constraints. The raw name is only ever sent on the wire (`tools/call`); the
|
|
59
|
+
* public name is never parsed to recover it.
|
|
60
|
+
*
|
|
61
|
+
* @module
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* DeepSeek function-name contract: at most 64 characters. Wire-protocol
|
|
65
|
+
* constant, not configuration.
|
|
66
|
+
*/
|
|
67
|
+
const MAX_PUBLIC_NAME_LENGTH = 64;
|
|
68
|
+
/** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */
|
|
69
|
+
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
|
|
70
|
+
/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
|
|
71
|
+
const HASH_LENGTH = 12;
|
|
72
|
+
/** Raw result record: the bridge owns JSON-value validation after transport. */
|
|
73
|
+
const RawCallToolResultSchema = z$1.record(z$1.string(), z$1.unknown());
|
|
74
|
+
/** List without mutating the SDK's per-page output-validator cache. */
|
|
75
|
+
function listToolsUncached(client, cursor) {
|
|
76
|
+
return client.request({
|
|
77
|
+
method: "tools/list",
|
|
78
|
+
...cursor === void 0 ? {} : { params: { cursor } }
|
|
79
|
+
}, ListToolsResultSchema);
|
|
80
|
+
}
|
|
81
|
+
/** Call without the SDK pre-validating an output schema the bridge may not support. */
|
|
82
|
+
function callToolUncached(client, rawName, args, exec, opts) {
|
|
83
|
+
return client.request({
|
|
84
|
+
method: "tools/call",
|
|
85
|
+
params: {
|
|
86
|
+
name: rawName,
|
|
87
|
+
arguments: args
|
|
88
|
+
}
|
|
89
|
+
}, RawCallToolResultSchema, {
|
|
90
|
+
signal: exec.signal,
|
|
91
|
+
timeout: opts.toolCallTimeoutMs
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Derive the model-facing public name for one MCP tool.
|
|
96
|
+
*
|
|
97
|
+
* Deterministic pure function of `(serverName, rawName)`: the clean case is
|
|
98
|
+
* `mcp__<serverName>__<rawName>` verbatim. When character replacement or
|
|
99
|
+
* truncation to the DeepSeek function-name contract (64 chars,
|
|
100
|
+
* `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the
|
|
101
|
+
* identity is appended so distinct MCP identities never collapse into the
|
|
102
|
+
* same public name.
|
|
103
|
+
*
|
|
104
|
+
* @param serverName - Stable local namespace from plugin config.
|
|
105
|
+
* @param rawName - The MCP server's own tool name.
|
|
106
|
+
* @returns The globally unique, model-facing ToolRuntime name.
|
|
107
|
+
*/
|
|
108
|
+
function publicToolName(serverName, rawName) {
|
|
109
|
+
const joined = `mcp__${serverName}__${rawName}`;
|
|
110
|
+
const normalized = joined.replace(INVALID_NAME_CHARS, "_");
|
|
111
|
+
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized;
|
|
112
|
+
const hash = createHash("sha256").update(`${serverName}\0${rawName}`).digest("hex").slice(0, HASH_LENGTH);
|
|
113
|
+
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Sync the MCP server's tool list into the harness ToolRuntime.
|
|
117
|
+
*
|
|
118
|
+
* Two phases keep the swap safe:
|
|
119
|
+
*
|
|
120
|
+
* 1. Fetch: drain uncached `tools/list` pagination and build the full next
|
|
121
|
+
* generation of `ToolDefinition`s under public names. Any failure here
|
|
122
|
+
* (network error, duplicate raw name in the server's list) rejects and
|
|
123
|
+
* leaves the previous generation registered untouched.
|
|
124
|
+
* 2. Swap: dispose the previous generation, register the new one. A registry
|
|
125
|
+
* conflict here can only mean a foreign registration squats on this
|
|
126
|
+
* server's `mcp__<serverName>__` namespace — the partial generation is
|
|
127
|
+
* rolled back (zero tools from this server) and logged. Initial strict
|
|
128
|
+
* synchronization may propagate the conflict so its parent transaction
|
|
129
|
+
* rejects; ordinary clients and later re-syncs return an empty map.
|
|
130
|
+
*
|
|
131
|
+
* @param client - Connected MCP Client instance used to list and call tools.
|
|
132
|
+
* @param ctx - Cordis context providing the `tools` service for registration.
|
|
133
|
+
* @param opts - Bridge options: server namespace and per-call timeout.
|
|
134
|
+
* @param previous - Disposer map from the prior sync generation; disposed
|
|
135
|
+
* during the swap phase (only after the fetch phase succeeded).
|
|
136
|
+
* @returns A map of registered public tool names to their unregister
|
|
137
|
+
* disposers — the exact set of live registrations owned by this server.
|
|
138
|
+
*/
|
|
139
|
+
async function syncTools(client, ctx, opts, previous) {
|
|
140
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
141
|
+
let cursor;
|
|
142
|
+
do {
|
|
143
|
+
const response = await listToolsUncached(client, cursor);
|
|
144
|
+
for (const tool of response.tools) {
|
|
145
|
+
const publicName = publicToolName(opts.serverName, tool.name);
|
|
146
|
+
if (definitions.has(publicName)) throw new Error(`mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`);
|
|
147
|
+
definitions.set(publicName, {
|
|
148
|
+
name: publicName,
|
|
149
|
+
description: tool.description ?? "",
|
|
150
|
+
parameters: tool.inputSchema,
|
|
151
|
+
output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
|
|
152
|
+
execute: createExecutor(client, tool.name, tool.execution?.taskSupport === "required", opts)
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
cursor = response.nextCursor;
|
|
156
|
+
} while (cursor);
|
|
157
|
+
// Tool-stability pass (dsh-mcp vendored extension): during a same-connection
|
|
158
|
+
// re-sync (e.g. a `tools/list_changed` notification) a tool whose public
|
|
159
|
+
// surface (name/description/parameters/output schema) is unchanged keeps its
|
|
160
|
+
// existing registration instead of being disposed and re-registered.
|
|
161
|
+
// Keeping the system-prompt tool list stable preserves prompt-cache hits;
|
|
162
|
+
// execution still calls the live MCP server, so server-side changes need no
|
|
163
|
+
// re-registration. A reconnected generation gets `stable: false` and rebuilds
|
|
164
|
+
// every tool because its executors bind the new client.
|
|
165
|
+
const kept = /* @__PURE__ */ new Set();
|
|
166
|
+
if (opts.stable !== false) {
|
|
167
|
+
for (const [publicName, definition] of definitions) {
|
|
168
|
+
if (!previous.has(publicName)) continue;
|
|
169
|
+
const existing = ctx.tools.get(publicName);
|
|
170
|
+
if (existing !== void 0 && toolPublicEqual(existing, definition)) kept.add(publicName);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const [publicName, dispose] of previous) {
|
|
174
|
+
if (!kept.has(publicName)) dispose();
|
|
175
|
+
}
|
|
176
|
+
const disposers = /* @__PURE__ */ new Map();
|
|
177
|
+
try {
|
|
178
|
+
for (const [publicName, definition] of definitions) {
|
|
179
|
+
if (kept.has(publicName)) {
|
|
180
|
+
disposers.set(publicName, previous.get(publicName));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
disposers.set(publicName, ctx.tools.register(definition));
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
for (const dispose of disposers.values()) dispose();
|
|
187
|
+
ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`);
|
|
188
|
+
if (opts.registrationFailure === "throw") throw error;
|
|
189
|
+
return /* @__PURE__ */ new Map();
|
|
190
|
+
}
|
|
191
|
+
return disposers;
|
|
192
|
+
}
|
|
193
|
+
/** Compare the public, cache-relevant surface of two tool definitions (dsh-mcp vendored). */
|
|
194
|
+
function toolPublicEqual(a, b) {
|
|
195
|
+
if (a.name !== b.name || a.description !== b.description) return false;
|
|
196
|
+
if (!jsonEqual(a.parameters, b.parameters)) return false;
|
|
197
|
+
return jsonEqual(a.output && a.output.schema, b.output && b.output.schema);
|
|
198
|
+
}
|
|
199
|
+
/** Deep equality of JSON-serializable values (schema objects). */
|
|
200
|
+
function jsonEqual(a, b) {
|
|
201
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
202
|
+
}
|
|
203
|
+
/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
|
|
204
|
+
function supportedOutputSchema(candidate) {
|
|
205
|
+
if (candidate === void 0) return void 0;
|
|
206
|
+
try {
|
|
207
|
+
assertSupportedJsonSchema(candidate);
|
|
208
|
+
return candidate;
|
|
209
|
+
} catch {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Build the canonical result schema and existing Native text projection. */
|
|
214
|
+
function createOutput(rawName, structuredSchema) {
|
|
215
|
+
return {
|
|
216
|
+
schema: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
content: {
|
|
220
|
+
type: "array",
|
|
221
|
+
items: {}
|
|
222
|
+
},
|
|
223
|
+
structuredContent: structuredSchema ?? {}
|
|
224
|
+
},
|
|
225
|
+
required: structuredSchema === void 0 ? ["content"] : ["content", "structuredContent"],
|
|
226
|
+
additionalProperties: false
|
|
227
|
+
},
|
|
228
|
+
render(_args, value) {
|
|
229
|
+
return [{
|
|
230
|
+
type: "text",
|
|
231
|
+
text: extractText(value.content, rawName)
|
|
232
|
+
}];
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Create an execute function for one MCP tool. The executor closes over the
|
|
238
|
+
* raw MCP tool name and sends an uncached `tools/call` request with it (never
|
|
239
|
+
* the public name), with abort signal and timeout, then maps the result to
|
|
240
|
+
* harness ContentBlocks. Owning the raw request prevents the SDK's internal
|
|
241
|
+
* per-page schema cache from pre-validating a different contract.
|
|
242
|
+
*
|
|
243
|
+
* When the MCP server returns `isError: true`, the executor throws so that
|
|
244
|
+
* the ToolRuntime's catch path produces an `isError` result for the model.
|
|
245
|
+
*/
|
|
246
|
+
function createExecutor(client, rawName, taskRequired, opts) {
|
|
247
|
+
return async (args, exec) => {
|
|
248
|
+
if (taskRequired) throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`);
|
|
249
|
+
const result = await callToolUncached(client, rawName, typeof args === "object" && args !== null ? args : {}, exec, opts);
|
|
250
|
+
if (!Array.isArray(result.content)) {
|
|
251
|
+
const rendered = "toolResult" in result ? JSON.stringify(result.toolResult) : "(no output)";
|
|
252
|
+
const text = typeof rendered === "string" ? rendered : "(no output)";
|
|
253
|
+
if (result.isError === true) throw new Error(text);
|
|
254
|
+
return {
|
|
255
|
+
content: [{
|
|
256
|
+
type: "text",
|
|
257
|
+
text
|
|
258
|
+
}],
|
|
259
|
+
...result.structuredContent !== void 0 ? { structuredContent: result.structuredContent } : {}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const content = result.content;
|
|
263
|
+
const text = extractText(content, rawName);
|
|
264
|
+
if (result.isError === true) throw new Error(text);
|
|
265
|
+
return {
|
|
266
|
+
content,
|
|
267
|
+
...result.structuredContent !== void 0 ? { structuredContent: result.structuredContent } : {}
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Extract text from an MCP content array into a single string.
|
|
273
|
+
* - text blocks: join with '\n'
|
|
274
|
+
* - image/audio/resource blocks: replaced with a placeholder
|
|
275
|
+
*
|
|
276
|
+
* Defensive: fields that the MCP spec declares required (mimeType, text) are
|
|
277
|
+
* guarded with fallbacks because this is a network trust boundary.
|
|
278
|
+
*/
|
|
279
|
+
function extractText(mcpContent, toolName) {
|
|
280
|
+
const parts = [];
|
|
281
|
+
for (const value of mcpContent) {
|
|
282
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
283
|
+
parts.push("[unsupported content type: unknown]");
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const block = value;
|
|
287
|
+
switch (block.type) {
|
|
288
|
+
case "text":
|
|
289
|
+
if (block.text !== void 0) parts.push(block.text);
|
|
290
|
+
break;
|
|
291
|
+
case "image":
|
|
292
|
+
parts.push(`[image: ${block.mimeType ?? "unknown"}, content discarded]`);
|
|
293
|
+
break;
|
|
294
|
+
case "audio":
|
|
295
|
+
parts.push(`[audio: ${block.mimeType ?? "unknown"}, content discarded]`);
|
|
296
|
+
break;
|
|
297
|
+
case "resource":
|
|
298
|
+
case "resource_link":
|
|
299
|
+
parts.push("[resource: content discarded]");
|
|
300
|
+
break;
|
|
301
|
+
default: parts.push(`[unsupported content type: ${block.type}]`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return parts.join("\n") || `(${toolName} returned no text content)`;
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region lib/types/connection.js
|
|
308
|
+
/**
|
|
309
|
+
* Connection supervisor: owns the MCP client/transport generations for one
|
|
310
|
+
* plugin instance, keeps the harness tool registry in sync with the live
|
|
311
|
+
* generation, and — when the connection drops — restarts the configured
|
|
312
|
+
* server with bounded exponential backoff.
|
|
313
|
+
*
|
|
314
|
+
* One outage shares one attempt budget (`maxAttempts` consecutive failed
|
|
315
|
+
* attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A
|
|
316
|
+
* connection that stays up past the stability window closes the outage, so
|
|
317
|
+
* the next disconnect starts a fresh budget while a crash-looping server —
|
|
318
|
+
* even one whose connects briefly succeed — still exhausts the cap instead of
|
|
319
|
+
* restarting forever. Exhaustion unregisters the server's tools and stops;
|
|
320
|
+
* disposal (including HMR) is the only way back from that state.
|
|
321
|
+
*
|
|
322
|
+
* @module
|
|
323
|
+
*/
|
|
324
|
+
/** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */
|
|
325
|
+
const RECONNECT_DEFAULTS = Object.freeze({
|
|
326
|
+
enabled: true,
|
|
327
|
+
initialDelayMs: 500,
|
|
328
|
+
maxDelayMs: 3e4,
|
|
329
|
+
maxAttempts: 10
|
|
330
|
+
});
|
|
331
|
+
const GENERATION_CLOSE_TIMEOUT_MS = 5e3;
|
|
332
|
+
/**
|
|
333
|
+
* The one explicit resolve step from raw reconnect config to the policy the
|
|
334
|
+
* supervisor runs. Programmatic construction may bypass Schemastery
|
|
335
|
+
* normalization, so every default and bound is re-judged here — misconfiguration
|
|
336
|
+
* fails the plugin instance at load.
|
|
337
|
+
*
|
|
338
|
+
* @param config - Raw `reconnect` config; omission uses the defaults.
|
|
339
|
+
* @param path - Diagnostic prefix naming the config location in thrown messages.
|
|
340
|
+
* @returns The frozen resolved policy.
|
|
341
|
+
*/
|
|
342
|
+
function resolveReconnectPolicy(config, path) {
|
|
343
|
+
if (config !== void 0) {
|
|
344
|
+
for (const key of Object.keys(config)) if (!Object.hasOwn(RECONNECT_DEFAULTS, key)) throw new Error(`${path}.${key} is not a reconnect option`);
|
|
345
|
+
}
|
|
346
|
+
const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled;
|
|
347
|
+
const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs;
|
|
348
|
+
const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs;
|
|
349
|
+
const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts;
|
|
350
|
+
if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
351
|
+
if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
352
|
+
if (initialDelayMs > maxDelayMs) throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`);
|
|
353
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) throw new Error(`${path}.maxAttempts must be a positive integer`);
|
|
354
|
+
return Object.freeze({
|
|
355
|
+
enabled,
|
|
356
|
+
initialDelayMs,
|
|
357
|
+
maxDelayMs,
|
|
358
|
+
maxAttempts
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Start the supervised connection for one MCP server and keep it alive per
|
|
363
|
+
* the reconnect policy.
|
|
364
|
+
*
|
|
365
|
+
* @param ctx - Cordis context providing the `tools` registry and logger.
|
|
366
|
+
* @param config - Resolved plugin config selecting the transport and server identity.
|
|
367
|
+
* @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
|
|
368
|
+
* @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
|
|
369
|
+
*/
|
|
370
|
+
function startConnection(ctx, config, policy) {
|
|
371
|
+
const label = `mcp-client(${config.serverName})`;
|
|
372
|
+
const opts = {
|
|
373
|
+
registrationFailure: "contain",
|
|
374
|
+
serverName: config.serverName,
|
|
375
|
+
toolCallTimeoutMs: config.toolCallTimeoutMs
|
|
376
|
+
};
|
|
377
|
+
const startupOpts = config.failOnStartupError ? {
|
|
378
|
+
...opts,
|
|
379
|
+
registrationFailure: "throw"
|
|
380
|
+
} : opts;
|
|
381
|
+
let disposed = false;
|
|
382
|
+
/** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */
|
|
383
|
+
let client;
|
|
384
|
+
/** Close signal paired with {@link client}; captured by dispose before current ownership is cleared. */
|
|
385
|
+
let clientClosed;
|
|
386
|
+
/** Live tool registrations owned by this server; only {@link enqueueSync} and dispose swap it. */
|
|
387
|
+
let disposers = /* @__PURE__ */ new Map();
|
|
388
|
+
let reconnectTimer;
|
|
389
|
+
/** Consecutive failed connection attempts within the current outage. */
|
|
390
|
+
let failedAttempts = 0;
|
|
391
|
+
/** When the current generation finished connect + initial sync; undefined while down. */
|
|
392
|
+
let connectedAt;
|
|
393
|
+
/** The real error from the first connection attempt, for startup-await diagnostics. */
|
|
394
|
+
let firstAttemptError;
|
|
395
|
+
/** A generation may act only while it is the current one on a live plugin. */
|
|
396
|
+
const isCurrent = (generation) => !disposed && client === generation;
|
|
397
|
+
/**
|
|
398
|
+
* Serializes every syncTools call — initial syncs and notification re-syncs
|
|
399
|
+
* across all generations — so two syncs can never interleave their
|
|
400
|
+
* dispose-previous/register-next swap (which would double-dispose one
|
|
401
|
+
* generation and leak another).
|
|
402
|
+
*/
|
|
403
|
+
let syncChain = Promise.resolve();
|
|
404
|
+
/** The client of the most recent sync (dsh-mcp vendored): a same-client re-sync may keep unchanged tools. */
|
|
405
|
+
let lastSyncedClient;
|
|
406
|
+
function enqueueSync(generation, syncOpts = opts) {
|
|
407
|
+
const run = syncChain.then(async () => {
|
|
408
|
+
if (!isCurrent(generation)) return;
|
|
409
|
+
// A same-client re-sync (notification) keeps unchanged tools; a fresh
|
|
410
|
+
// generation (reconnect) must rebuild executors for the new client.
|
|
411
|
+
const stable = generation === lastSyncedClient;
|
|
412
|
+
lastSyncedClient = generation;
|
|
413
|
+
disposers = await syncTools(generation, ctx, { ...syncOpts, stable }, disposers);
|
|
414
|
+
});
|
|
415
|
+
syncChain = run.catch(() => {});
|
|
416
|
+
return run;
|
|
417
|
+
}
|
|
418
|
+
/** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */
|
|
419
|
+
function generationDown(generation) {
|
|
420
|
+
if (!isCurrent(generation)) return;
|
|
421
|
+
client = void 0;
|
|
422
|
+
clientClosed = void 0;
|
|
423
|
+
scheduleReconnect();
|
|
424
|
+
}
|
|
425
|
+
/** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */
|
|
426
|
+
function waitForClose(closed) {
|
|
427
|
+
return new Promise((resolve) => {
|
|
428
|
+
const timeout = setTimeout(() => {
|
|
429
|
+
resolve(false);
|
|
430
|
+
}, GENERATION_CLOSE_TIMEOUT_MS);
|
|
431
|
+
timeout.unref();
|
|
432
|
+
closed.then(() => {
|
|
433
|
+
clearTimeout(timeout);
|
|
434
|
+
resolve(true);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
function scheduleReconnect() {
|
|
439
|
+
const lostEstablishedConnection = connectedAt !== void 0;
|
|
440
|
+
if (!policy.enabled) {
|
|
441
|
+
const message = lostEstablishedConnection ? "connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart" : "connection failed and reconnect is disabled — no tools were registered; reload the plugin or restart the Host to connect";
|
|
442
|
+
ctx.logger.error(`${label}: ${message}`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (connectedAt !== void 0 && Date.now() - connectedAt >= policy.maxDelayMs) failedAttempts = 0;
|
|
446
|
+
connectedAt = void 0;
|
|
447
|
+
failedAttempts += 1;
|
|
448
|
+
if (failedAttempts > policy.maxAttempts) {
|
|
449
|
+
syncChain = syncChain.then(() => {
|
|
450
|
+
for (const dispose of disposers.values()) dispose();
|
|
451
|
+
disposers = /* @__PURE__ */ new Map();
|
|
452
|
+
});
|
|
453
|
+
ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect`);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1));
|
|
457
|
+
const action = lostEstablishedConnection ? "connection lost; reconnecting" : "connection failed; retrying";
|
|
458
|
+
ctx.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`);
|
|
459
|
+
reconnectTimer = setTimeout(() => {
|
|
460
|
+
reconnectTimer = void 0;
|
|
461
|
+
settling = connectGeneration(false);
|
|
462
|
+
}, delayMs);
|
|
463
|
+
reconnectTimer.unref();
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* One connection attempt: fresh transport + client (the MCP SDK binds a
|
|
467
|
+
* Protocol to one transport for life), connect, then queue the initial tool
|
|
468
|
+
* sync. The startup flag belongs to the attempt rather than the shared sync
|
|
469
|
+
* queue, so an early notification cannot consume strict startup semantics.
|
|
470
|
+
* Every failure funnels through {@link generationDown}; success arms the
|
|
471
|
+
* onclose-driven disconnect path. Never rejects.
|
|
472
|
+
*
|
|
473
|
+
* @param startup - Whether this is the plugin's activation attempt.
|
|
474
|
+
*/
|
|
475
|
+
async function connectGeneration(startup) {
|
|
476
|
+
const generation = new Client({
|
|
477
|
+
name: "dsh-mcp-client",
|
|
478
|
+
version: "0.0.1"
|
|
479
|
+
}, { capabilities: {} });
|
|
480
|
+
const closed = Promise.withResolvers();
|
|
481
|
+
let attemptSettled = false;
|
|
482
|
+
let closeObserved = false;
|
|
483
|
+
const hasClosed = () => closeObserved;
|
|
484
|
+
client = generation;
|
|
485
|
+
clientClosed = closed.promise;
|
|
486
|
+
generation.onclose = () => {
|
|
487
|
+
closeObserved = true;
|
|
488
|
+
closed.resolve();
|
|
489
|
+
if (attemptSettled) generationDown(generation);
|
|
490
|
+
};
|
|
491
|
+
generation.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
492
|
+
if (!isCurrent(generation)) return;
|
|
493
|
+
ctx.logger.info(`${label}: tool list changed, re-syncing`);
|
|
494
|
+
try {
|
|
495
|
+
await enqueueSync(generation);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
try {
|
|
501
|
+
await generation.connect(createTransport(config));
|
|
502
|
+
if (hasClosed()) {
|
|
503
|
+
attemptSettled = true;
|
|
504
|
+
generationDown(generation);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
await enqueueSync(generation, startup ? startupOpts : opts);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (firstAttemptError === void 0) firstAttemptError = error;
|
|
510
|
+
if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`);
|
|
511
|
+
try {
|
|
512
|
+
await generation.close();
|
|
513
|
+
} catch {}
|
|
514
|
+
const quiesced = hasClosed() || await waitForClose(closed.promise);
|
|
515
|
+
attemptSettled = true;
|
|
516
|
+
if (!isCurrent(generation)) return;
|
|
517
|
+
if (!quiesced) {
|
|
518
|
+
client = void 0;
|
|
519
|
+
clientClosed = void 0;
|
|
520
|
+
ctx.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
generationDown(generation);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
attemptSettled = true;
|
|
527
|
+
if (hasClosed()) {
|
|
528
|
+
generationDown(generation);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (!isCurrent(generation)) return;
|
|
532
|
+
connectedAt = Date.now();
|
|
533
|
+
if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`);
|
|
534
|
+
}
|
|
535
|
+
/** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */
|
|
536
|
+
let settling = connectGeneration(true);
|
|
537
|
+
return {
|
|
538
|
+
ready: settling.then(() => {
|
|
539
|
+
if (client !== void 0) return {};
|
|
540
|
+
/* v8 ignore next -- defensive: firstAttemptError is always set when connect/sync fails */
|
|
541
|
+
return { error: firstAttemptError ?? /* @__PURE__ */ new Error(`${label}: initial connection failed`) };
|
|
542
|
+
}),
|
|
543
|
+
async dispose() {
|
|
544
|
+
disposed = true;
|
|
545
|
+
if (reconnectTimer !== void 0) {
|
|
546
|
+
clearTimeout(reconnectTimer);
|
|
547
|
+
reconnectTimer = void 0;
|
|
548
|
+
}
|
|
549
|
+
const current = client;
|
|
550
|
+
const currentClosed = clientClosed;
|
|
551
|
+
client = void 0;
|
|
552
|
+
clientClosed = void 0;
|
|
553
|
+
if (current !== void 0) {
|
|
554
|
+
try {
|
|
555
|
+
await current.close();
|
|
556
|
+
} catch {}
|
|
557
|
+
if (currentClosed !== void 0 && !await waitForClose(currentClosed)) ctx.logger.error(`${label}: generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms during disposal — server shutdown may be incomplete`);
|
|
558
|
+
}
|
|
559
|
+
await settling;
|
|
560
|
+
await syncChain;
|
|
561
|
+
for (const dispose of disposers.values()) dispose();
|
|
562
|
+
disposers = /* @__PURE__ */ new Map();
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
//#endregion
|
|
567
|
+
//#region lib/types/probe.js
|
|
568
|
+
/**
|
|
569
|
+
* One-shot connection probe: connects to an MCP server, lists its tools, and
|
|
570
|
+
* closes the transport. Registers nothing — management surfaces use it to test
|
|
571
|
+
* a server configuration before it is kept or mounted, so a probe must never
|
|
572
|
+
* touch `ctx.tools` or any plugin lifecycle.
|
|
573
|
+
*
|
|
574
|
+
* @module
|
|
575
|
+
*/
|
|
576
|
+
/** Default overall deadline for one probe (ms). */
|
|
577
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 15e3;
|
|
578
|
+
/** Keep a thrown value readable for a management UI. */
|
|
579
|
+
function probeErrorMessage(error) {
|
|
580
|
+
return error instanceof Error ? error.message : String(error);
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Connect, drain the server's `tools/list` pagination, then close.
|
|
584
|
+
*
|
|
585
|
+
* @param client - Fresh, unconnected MCP Client for this probe.
|
|
586
|
+
* @param config - Resolved plugin config selecting transport and server identity.
|
|
587
|
+
* @returns The discovered tool summary.
|
|
588
|
+
*/
|
|
589
|
+
async function runProbe(client, config) {
|
|
590
|
+
await client.connect(createTransport(config));
|
|
591
|
+
const tools = [];
|
|
592
|
+
let cursor;
|
|
593
|
+
do {
|
|
594
|
+
const response = await client.request({
|
|
595
|
+
method: "tools/list",
|
|
596
|
+
...cursor === void 0 ? {} : { params: { cursor } }
|
|
597
|
+
}, ListToolsResultSchema);
|
|
598
|
+
for (const tool of response.tools) tools.push({
|
|
599
|
+
name: tool.name,
|
|
600
|
+
...tool.description === void 0 ? {} : { description: tool.description }
|
|
601
|
+
});
|
|
602
|
+
cursor = response.nextCursor;
|
|
603
|
+
} while (cursor);
|
|
604
|
+
return {
|
|
605
|
+
ok: true,
|
|
606
|
+
tools
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Probe one MCP server configuration without registering anything.
|
|
611
|
+
*
|
|
612
|
+
* The overall connect + list attempt races a deadline; a timeout returns a
|
|
613
|
+
* failure rather than hanging the caller. The transport is always closed on
|
|
614
|
+
* the way out — for stdio that terminates the probed child process.
|
|
615
|
+
*
|
|
616
|
+
* @param config - Transport and server identity to probe.
|
|
617
|
+
* @param options - Optional probe deadline override.
|
|
618
|
+
* @returns the tool listing, or a readable failure.
|
|
619
|
+
*/
|
|
620
|
+
async function probeConnection(config, options = {}) {
|
|
621
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
622
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error(`probeConnection: timeoutMs must be a positive finite number, received ${String(timeoutMs)}`);
|
|
623
|
+
const client = new Client({
|
|
624
|
+
name: "dsh-mcp-client",
|
|
625
|
+
version: "0.0.1"
|
|
626
|
+
}, { capabilities: {} });
|
|
627
|
+
const timeout = new Promise((_, reject) => {
|
|
628
|
+
setTimeout(() => reject(/* @__PURE__ */ new Error(`connection probe timed out after ${timeoutMs}ms`)), timeoutMs).unref();
|
|
629
|
+
});
|
|
630
|
+
try {
|
|
631
|
+
return await Promise.race([runProbe(client, config), timeout]);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
return {
|
|
634
|
+
ok: false,
|
|
635
|
+
message: probeErrorMessage(error)
|
|
636
|
+
};
|
|
637
|
+
} finally {
|
|
638
|
+
try {
|
|
639
|
+
await client.close();
|
|
640
|
+
} catch {}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region lib/types/index.js
|
|
645
|
+
/**
|
|
646
|
+
* MCP client bridge plugin: connects to an external MCP server and registers
|
|
647
|
+
* its tools on `ctx.tools` under server-qualified public names
|
|
648
|
+
* (`mcp__<serverName>__<rawName>`). Each plugin instance connects to one MCP
|
|
649
|
+
* server; load multiple instances in `cordis.yml` for multiple servers.
|
|
650
|
+
*
|
|
651
|
+
* Namespace plugin (named exports, no default export). Lifecycle is
|
|
652
|
+
* effect-scoped: disposal disconnects from the server, unregisters all tools,
|
|
653
|
+
* and releases the `serverName` namespace reservation. HMR hot-swaps by
|
|
654
|
+
* disposing the old instance and creating a new one; identical `serverName`
|
|
655
|
+
* reproduces identical public tool names.
|
|
656
|
+
*
|
|
657
|
+
* @module @deepseek-ai/dsh-mcp-client
|
|
658
|
+
*/
|
|
659
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
660
|
+
const name = "mcp-client";
|
|
661
|
+
/** Services required by this plugin. */
|
|
662
|
+
const inject = ["tools"];
|
|
663
|
+
/** Default timeout for individual MCP tool calls (ms). */
|
|
664
|
+
const DEFAULT_TOOL_CALL_TIMEOUT_MS = 6e4;
|
|
665
|
+
/** Valid `serverName`, kept below the public tool-name budget. */
|
|
666
|
+
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
|
|
667
|
+
/**
|
|
668
|
+
* Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps
|
|
669
|
+
* in one process — tests — must not see each other's names). A duplicate
|
|
670
|
+
* namespace is a configuration error surfaced at plugin load, never silent
|
|
671
|
+
* shadowing.
|
|
672
|
+
*/
|
|
673
|
+
const activeServerNames = /* @__PURE__ */ new WeakMap();
|
|
674
|
+
const Reconnect = z.object({
|
|
675
|
+
enabled: z.boolean().default(RECONNECT_DEFAULTS.enabled),
|
|
676
|
+
initialDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.initialDelayMs),
|
|
677
|
+
maxDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.maxDelayMs),
|
|
678
|
+
maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(RECONNECT_DEFAULTS.maxAttempts)
|
|
679
|
+
});
|
|
680
|
+
const Config = z.union([z.object({
|
|
681
|
+
transport: z.const("stdio"),
|
|
682
|
+
serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
|
|
683
|
+
command: z.string().required(),
|
|
684
|
+
args: z.array(String).default([]),
|
|
685
|
+
env: z.dict(String).default({}),
|
|
686
|
+
cwd: z.string().default(""),
|
|
687
|
+
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
|
688
|
+
failOnStartupError: z.boolean().default(false),
|
|
689
|
+
reconnect: Reconnect
|
|
690
|
+
}), z.object({
|
|
691
|
+
transport: z.const("streamable-http"),
|
|
692
|
+
serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
|
|
693
|
+
url: z.string().required(),
|
|
694
|
+
headers: z.dict(String).default({}),
|
|
695
|
+
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
|
696
|
+
failOnStartupError: z.boolean().default(false),
|
|
697
|
+
reconnect: Reconnect
|
|
698
|
+
})]);
|
|
699
|
+
/**
|
|
700
|
+
* Connect one MCP server and publish its initial tool generation before activation.
|
|
701
|
+
* This entry remains explicitly `async`: Cordis treats a prototype-bearing
|
|
702
|
+
* ordinary function as a constructor, whose returned Promise is not startup work.
|
|
703
|
+
* @param ctx - plugin context carrying the tool registry.
|
|
704
|
+
* @param config - resolved transport and server namespace configuration.
|
|
705
|
+
* @returns startup readiness after connection and initial tool discovery settle.
|
|
706
|
+
*/
|
|
707
|
+
async function apply(ctx, config) {
|
|
708
|
+
const reconnect = resolveReconnectPolicy(config.reconnect, `mcp-client(${config.serverName}): reconnect`);
|
|
709
|
+
ctx.effect(() => {
|
|
710
|
+
let names = activeServerNames.get(ctx.root);
|
|
711
|
+
if (!names) {
|
|
712
|
+
names = /* @__PURE__ */ new Set();
|
|
713
|
+
activeServerNames.set(ctx.root, names);
|
|
714
|
+
}
|
|
715
|
+
if (names.has(config.serverName)) throw new Error(`mcp-client: serverName "${config.serverName}" is already in use by another mcp-client instance — pick a unique serverName in cordis.yml`);
|
|
716
|
+
names.add(config.serverName);
|
|
717
|
+
return () => void names.delete(config.serverName);
|
|
718
|
+
}, "mcp-client.serverName");
|
|
719
|
+
const connection = startConnection(ctx, config, reconnect);
|
|
720
|
+
ctx.effect(() => {
|
|
721
|
+
return () => connection.dispose();
|
|
722
|
+
}, "mcp-client.connection");
|
|
723
|
+
const outcome = await connection.ready;
|
|
724
|
+
if (outcome.error !== void 0 && config.failOnStartupError) throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error });
|
|
725
|
+
}
|
|
726
|
+
//#endregion
|
|
727
|
+
export { Config, apply, inject, name, probeConnection };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "MCP
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "MCP 服务器管理插件:可视化界面管理 MCP 服务器与工具(配置/启停/刷新/测试、工具级勾选),支持按需检索(tool search)热注入,省 token。DeepSeek Harness plugin: visual MCP server & tool management with on-demand tool search injection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
Binary file
|