dsh-plugin-mcp-toggles 0.0.0-stage → 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZhaoHanshao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,111 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-mcp-toggles
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/dsh-plugin-mcp-toggles.svg)](https://www.npmjs.com/package/dsh-plugin-mcp-toggles)
4
+ [![license](https://img.shields.io/npm/l/dsh-plugin-mcp-toggles.svg)](LICENSE)
5
+ [![node](https://img.shields.io/node/v/dsh-plugin-mcp-toggles.svg)](package.json)
6
+
7
+ 为**每个会话**单独开关已安装的 MCP 服务器。
8
+
9
+ ## 它解决什么
10
+
11
+ MCP 服务器在 DSH profile 里是进程级的:`@deepseek-ai/dsh-mcp-client` 每行连一个
12
+ 服务器,把 `mcp__<serverName>__<tool>` 注册进**全局**工具层。于是每个会话都看到
13
+ 同一批 MCP 工具 —— 想让 A 会话只用 REA、B 会话只用别的服务器,原生做不到。
14
+
15
+ 装上本插件后:会话标题栏右侧出现一个 **MCP** 按钮,点开列出已安装的 MCP 服务器,
16
+ 每行一个开关,**只影响当前会话**。新会话默认全部开启(装上插件不会悄悄改变任何
17
+ 人现有的能力)。
18
+
19
+ ## 安装
20
+
21
+ 这是一个标准的 DSH bundle 包:`package.json` 里用 `dsh.bundle.patch` 声明要插入的
22
+ 插件行,用 `dsh.client` 声明 Web 端入口,没有依赖、没有构建步骤,装上即可用。
23
+
24
+ 从 npm 装进当前 profile:
25
+
26
+ ```
27
+ plugin_manager action=install_bundle target=dsh-plugin-mcp-toggles
28
+ ```
29
+
30
+ `target` 接受任意安装 spec:
31
+
32
+ ```
33
+ plugin_manager action=install_bundle target=dsh-plugin-mcp-toggles@0.1.0
34
+ plugin_manager action=install_bundle target=github:ZhaoHanshao/dsh-plugin-mcp-toggles
35
+ ```
36
+
37
+ 装好后重启 DSH,打开任意会话即可看到按钮。
38
+
39
+ 想先读代码或自己打包检查文件清单:
40
+
41
+ ```bash
42
+ npm install dsh-plugin-mcp-toggles
43
+ npm pack dsh-plugin-mcp-toggles --dry-run # 只打印将要发布的文件
44
+ ```
45
+
46
+ 它需要宿主提供 `webServer` 服务与 `conversation.session.header.utilities` /
47
+ `conversation.hero.options` 两个槽位,因此要求 DSH 版本不低于它开发时的
48
+ `@deepseek-ai/dsh-client-ui-conversation`(运行时 **0.2.1-rc.3**)。这条约束写进了
49
+ `peerDependencies`,但标记为可选 —— DSH 把这些包内置在自己的安装目录里,普通 npm
50
+ 安装不该为它再拉一份副本。
51
+
52
+ Node 版本要求 `^22.19.0 || >=24.0.0`,与 DSH 官方下限一致。
53
+
54
+ ## 用法
55
+
56
+ 1. 打开任意会话 —— 或直接停在**新会话页**。
57
+ 2. 点标题栏(新会话页则是输入卡片上方那一行)的 **MCP** 按钮。
58
+ 3. 逐行开关,或点 `全部开启` / `全部关闭`。
59
+
60
+ 按钮上的徽标显示当前状态:`全部开启`、`n/m`、`全部关闭`;部分关闭时按钮转为警示色。
61
+
62
+ ## 两个入口,同一个开关集合
63
+
64
+ | 槽位 | 出现位置 |
65
+ |---|---|
66
+ | `conversation.session.header.utilities` | 已有会话的标题栏右侧 |
67
+ | `conversation.hero.options` | 新会话落地页,紧跟 workspace 与模式控件之后 |
68
+
69
+ 两处**必须都有**:新会话页的会话页头带 `hideChrome`
70
+ (`blank && composerPhase === 'blank'`,见 `ConversationSession.tsx`),此时整个
71
+ header 都不渲染,只挂标题栏会让按钮在新会话页上完全消失。两个槽都提供同一个
72
+ `sessionId`,所以在哪一处切换都是同一个会话的开关 —— 在新会话页关掉的服务器,
73
+ 进入对话后依然关着。
74
+
75
+ ## 它怎么工作
76
+
77
+ `agent.ctx.tools.restrict({ deny })` 是**作用域内**生效的全局工具掩码
78
+ (`packages/core/tools/src/index.ts`)。可见性、schema 投影与派发都走同一个解析器,
79
+ 因此被关掉的工具模型既看不到也调不到,其他会话不受影响。
80
+
81
+ - 关闭某服务器 → 把它的 `mcp__<server>__*` 工具加进该会话的 deny 集。
82
+ - 全部服务器都关闭时,共享的 `list_mcp_resources` 等资源工具一并隐藏
83
+ (`sharedResourceTools: false` 可关掉这个联动)。
84
+ - 开关立即生效,无需重启会话或重开服务器。
85
+
86
+ ## 开关存在哪
87
+
88
+ `$DSH_HOME/mcp-toggles-state.json`:
89
+
90
+ ```json
91
+ { "bySession": { "<sessionId>": { "rea": false } } }
92
+ ```
93
+
94
+ 键是 sessionId。**没有记录即视为开启** —— 只会记录「关掉」,所以新会话、以及删掉
95
+ 状态文件之后的会话,都回到全部开启。
96
+
97
+ ## 配置
98
+
99
+ `cordis.patch.yml` 的行配置:
100
+
101
+ | 字段 | 默认 | 含义 |
102
+ |---|---|---|
103
+ | `sharedResourceTools` | `true` | 关闭全部 MCP 时是否连共享资源工具一起隐藏。 |
104
+ | `extraServers` | `[]` | 额外要认作 MCP 服务器的名字;用于服务器暂时没有注册任何工具时也能列出来。 |
105
+
106
+ ## 已知限制
107
+
108
+ - 只管理 **MCP** 工具(`mcp__*` 前缀与共享资源工具),不动其他工具。
109
+ - 服务器列表来自 `configEditor.entries()` 里的 `@deepseek-ai/dsh-mcp-client` 行;
110
+ `configEditor` 不可用时退回当前工具名前缀,此时**没有工具**的服务器不会出现在列表里。
111
+ - 开关按会话 id 保存;会话被删除后对应记录会保留(无害,不会影响其他会话)。
@@ -0,0 +1,5 @@
1
+ - insert:
2
+ - id: mcp-toggles
3
+ name: 'dsh-plugin-mcp-toggles'
4
+ config:
5
+ sharedResourceTools: true
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none">
2
+ <path d="M8.5 2.5v4.2M15.5 2.5v4.2" stroke="#1f2933" stroke-width="1.6" stroke-linecap="round"/>
3
+ <path d="M5.6 6.7h12.8v3.6a6.4 6.4 0 0 1-12.8 0V6.7Z" stroke="#1f2933" stroke-width="1.6" stroke-linejoin="round"/>
4
+ <path d="M12 16.7V21" stroke="#1f2933" stroke-width="1.6" stroke-linecap="round"/>
5
+ <circle cx="19" cy="18.5" r="4.1" fill="#247bbf"/>
6
+ <path d="M16.9 18.5h4.2" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/>
7
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,473 @@
1
+ /**
2
+ * dsh-plugin-mcp-toggles — 客户端半。
3
+ *
4
+ * 在**会话标题栏**放一个 MCP 选择按钮:点开后列出已安装的 MCP 服务器,
5
+ * 每行一个开关,只作用于当前会话。不占侧栏入口,也没有独立页面 ——
6
+ * 开关就发生在会话本身里。
7
+ *
8
+ * 模块自包含:只依赖平台种子 `react`。
9
+ */
10
+
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-plugin-mcp-toggles',
13
+ factory: (require) => {
14
+ const React = require('react')
15
+
16
+ const NS = 'mcp-toggles'
17
+ const API = '/mcp-toggles/api'
18
+ const TOKEN_META = 'dsh-mcp-toggles'
19
+
20
+ // #region 文案
21
+
22
+ const zh = {
23
+ 'picker.title': '选择本会话可用的 MCP 服务器',
24
+ 'picker.loading': '读取中…',
25
+ 'picker.empty': '没有已安装的 MCP 服务器',
26
+ 'picker.emptyHint': '在 设置 → 插件 里安装一个 MCP 服务器后,它会出现在这里。',
27
+ 'picker.failed': '读取失败',
28
+ 'picker.retry': '重试',
29
+ 'picker.scope': '仅本会话生效',
30
+ 'picker.scopeDefault': '将用于之后新建的会话',
31
+ 'picker.allOn': '全部开启',
32
+ 'picker.allOff': '全部关闭',
33
+ 'picker.on': '开',
34
+ 'picker.off': '关',
35
+ 'picker.disconnected': '未连接',
36
+ 'picker.tools': '个工具',
37
+ 'picker.shadowed': '本会话隐藏',
38
+ 'picker.summary.all': '全部开启',
39
+ 'picker.summary.none': '全部关闭',
40
+ 'picker.summary.pending': '选择 MCP',
41
+ 'picker.saving': '保存中…',
42
+ 'picker.unsaved': '未写入磁盘',
43
+ }
44
+
45
+ const en = {
46
+ 'picker.title': 'Choose the MCP servers this conversation can use',
47
+ 'picker.loading': 'Loading…',
48
+ 'picker.empty': 'No MCP server is installed',
49
+ 'picker.emptyHint': 'Install an MCP server under Settings → Plugins and it shows up here.',
50
+ 'picker.failed': 'Load failed',
51
+ 'picker.retry': 'Retry',
52
+ 'picker.scope': 'Applies to this conversation only',
53
+ 'picker.scopeDefault': 'Applies to conversations you start from here',
54
+ 'picker.allOn': 'Turn all on',
55
+ 'picker.allOff': 'Turn all off',
56
+ 'picker.on': 'On',
57
+ 'picker.off': 'Off',
58
+ 'picker.disconnected': 'not connected',
59
+ 'picker.tools': 'tools',
60
+ 'picker.shadowed': 'hidden here',
61
+ 'picker.summary.all': 'All on',
62
+ 'picker.summary.none': 'All off',
63
+ 'picker.summary.pending': 'MCP',
64
+ 'picker.saving': 'Saving…',
65
+ 'picker.unsaved': 'not written to disk',
66
+ }
67
+
68
+ // #endregion
69
+
70
+ // #region 令牌与请求
71
+
72
+ let cachedToken
73
+
74
+ /** 从页面 meta 读令牌;插件晚于页面加载时退回路由获取。 */
75
+ async function token() {
76
+ if (cachedToken !== undefined) return cachedToken
77
+ const meta = document.querySelector(`meta[name="${TOKEN_META}"]`)
78
+ if (meta !== null) {
79
+ const value = meta.getAttribute('content')
80
+ if (value !== null && value.length > 0) {
81
+ cachedToken = value
82
+ return cachedToken
83
+ }
84
+ }
85
+ const response = await fetch(`${API}/token`, { credentials: 'same-origin' })
86
+ if (!response.ok) throw new Error(`token ${response.status}`)
87
+ cachedToken = (await response.json()).token
88
+ return cachedToken
89
+ }
90
+
91
+ /** 统一请求;非 2xx 抛错并带上服务端消息。 */
92
+ async function api(path, options = {}) {
93
+ const headers = { 'x-mcp-toggles-token': await token() }
94
+ if (options.payload !== undefined) headers['content-type'] = 'application/json'
95
+ const response = await fetch(`${API}${path}`, {
96
+ method: options.method ?? 'GET',
97
+ headers,
98
+ credentials: 'same-origin',
99
+ signal: options.signal,
100
+ ...options.payload === undefined ? {} : { body: JSON.stringify(options.payload) },
101
+ })
102
+ const payload = await response.json().catch(() => ({}))
103
+ if (!response.ok) throw new Error(payload?.error?.message ?? `HTTP ${response.status}`)
104
+ return payload
105
+ }
106
+
107
+ // #endregion
108
+
109
+ // #region 图标
110
+
111
+ /** 插头图标:一眼看出这是「连接」类开关。 */
112
+ function PlugGlyph({ size = 15 }) {
113
+ return React.createElement('svg', {
114
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true,
115
+ },
116
+ React.createElement('path', {
117
+ d: 'M9 3v5M15 3v5',
118
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round',
119
+ }),
120
+ React.createElement('path', {
121
+ d: 'M6.5 8h11v3.2a5.5 5.5 0 0 1-11 0V8Z',
122
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinejoin: 'round',
123
+ }),
124
+ React.createElement('path', {
125
+ d: 'M12 16.7V21',
126
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round',
127
+ }))
128
+ }
129
+
130
+ /** 小箭头。 */
131
+ function Caret() {
132
+ return React.createElement('svg', {
133
+ width: 10, height: 10, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true,
134
+ }, React.createElement('path', {
135
+ d: 'm6 9 6 6 6-6',
136
+ stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round', strokeLinejoin: 'round',
137
+ }))
138
+ }
139
+
140
+ // #endregion
141
+
142
+ // 与同区域的系统按钮(open-in-app / agent-terminal / schedule-catalog)保持
143
+ // 同一套尺寸:高 24px、字号 11px、0.5px 边框、--dsw-radius-sm。
144
+ const STYLE = `
145
+ .mt-wrap { position: relative; display: inline-flex; flex: none; align-self: center; }
146
+ .mt-trigger {
147
+ box-sizing: border-box; display: inline-flex; align-items: center; gap: 4px;
148
+ height: 24px; padding: 3px 6px; max-width: 190px;
149
+ border: .5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-sm);
150
+ background: 0 0; color: var(--dsw-alias-label-primary);
151
+ font-family: var(--dsw-font-family); font-size: 11px; line-height: 16px;
152
+ white-space: nowrap; cursor: pointer;
153
+ }
154
+ .mt-trigger:hover { background: var(--dsw-alias-interactive-bg-hover); }
155
+ .mt-trigger:focus-visible { background: var(--dsw-alias-interactive-bg-hover); outline: none; }
156
+ .mt-trigger[data-open="true"] { border-color: var(--dsw-alias-border-l2); background: var(--dsw-alias-interactive-bg-hover); }
157
+ .mt-trigger[data-partial="true"] { color: var(--dsw-alias-state-warn-primary); }
158
+ .mt-trigger[data-none="true"] { color: var(--dsw-alias-label-secondary); }
159
+ /* 新会话落地页那一行比标题栏更矮更紧凑,和相邻的 workspace/preset 控件同高。 */
160
+ .mt-trigger[data-compact="true"] { height: 22px; padding: 2px 6px; font-size: 10.5px; }
161
+ .mt-link[data-disabled="true"] { opacity: .5; cursor: default; pointer-events: none; }
162
+ .mt-icon { flex: none; display: inline-flex; align-items: center; opacity: .9; }
163
+ .mt-name { overflow: hidden; text-overflow: ellipsis; }
164
+ .mt-caret { flex: none; display: inline-flex; align-items: center; color: var(--dsw-alias-label-secondary); transition: transform .12s ease; }
165
+ .mt-trigger[data-open="true"] .mt-caret { transform: rotate(180deg); }
166
+ .mt-menu {
167
+ position: absolute; top: calc(100% + 5px); right: 0; z-index: 60;
168
+ width: 330px; max-height: 60vh; overflow-y: auto; padding: 4px;
169
+ border: .5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-md);
170
+ background: var(--dsw-alias-bg-overlay); box-shadow: 0 8px 24px rgba(0,0,0,.16);
171
+ }
172
+ .mt-head {
173
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
174
+ padding: 5px 8px 6px; font-size: 11px; line-height: 16px;
175
+ color: var(--dsw-alias-label-secondary);
176
+ }
177
+ .mt-headActions { display: inline-flex; align-items: center; gap: 8px; flex: none; }
178
+ .mt-link { cursor: pointer; color: var(--dsw-alias-label-secondary); }
179
+ .mt-link:hover { color: var(--dsw-alias-brand-primary); }
180
+ .mt-item {
181
+ display: flex; align-items: center; gap: 8px; padding: 6px 8px;
182
+ border-radius: var(--dsw-radius-sm); cursor: pointer;
183
+ }
184
+ .mt-item:hover { background: var(--dsw-alias-interactive-bg-hover); }
185
+ .mt-item[data-busy="true"] { opacity: .6; cursor: default; }
186
+ .mt-body { flex: 1; min-width: 0; }
187
+ .mt-title { display: flex; align-items: center; gap: 5px; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-primary); }
188
+ .mt-sub {
189
+ margin-top: 1px; font-size: 10px; line-height: 14px;
190
+ color: var(--dsw-alias-label-secondary);
191
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
192
+ }
193
+ .mt-badge {
194
+ flex: none; padding: 0 4px; border-radius: var(--dsw-radius-xs);
195
+ font-size: 9.5px; line-height: 14px;
196
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
197
+ }
198
+ .mt-badge[data-tone="warn"] { color: var(--dsw-alias-state-warn-primary); }
199
+ .mt-switch {
200
+ flex: none; position: relative; box-sizing: border-box;
201
+ width: 26px; height: 15px; padding: 0; border-radius: 999px;
202
+ border: .5px solid var(--dsw-alias-border-l2);
203
+ background: var(--dsw-alias-bg-layer-2); cursor: pointer; transition: background .12s ease;
204
+ }
205
+ .mt-switch::after {
206
+ content: ""; position: absolute; top: 1.5px; left: 2px;
207
+ width: 10px; height: 10px; border-radius: 50%;
208
+ background: var(--dsw-alias-label-secondary); transition: transform .12s ease, background .12s ease;
209
+ }
210
+ .mt-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
211
+ .mt-switch[aria-checked="true"]::after { transform: translateX(11px); background: var(--dsw-alias-bg-base); }
212
+ .mt-switch:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
213
+ .mt-switch:disabled { opacity: .5; cursor: default; }
214
+ .mt-sep { height: .5px; margin: 4px 2px; background: var(--dsw-alias-border-l1); }
215
+ .mt-foot {
216
+ display: flex; align-items: center; gap: 6px; padding: 5px 8px;
217
+ font-size: 10.5px; line-height: 15px; color: var(--dsw-alias-label-secondary);
218
+ }
219
+ .mt-err { padding: 3px 8px 6px; font-size: 10.5px; line-height: 15px; color: var(--dsw-alias-state-error-primary); }
220
+ .mt-btn {
221
+ padding: 3px 8px; border: .5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-sm);
222
+ background: 0 0; color: var(--dsw-alias-label-primary);
223
+ font-family: var(--dsw-font-family); font-size: 11px; line-height: 16px; cursor: pointer;
224
+ }
225
+ .mt-btn:hover { background: var(--dsw-alias-interactive-bg-hover); }
226
+ .mt-btn:disabled { opacity: .5; cursor: default; }
227
+ `
228
+
229
+ // #region 选择器
230
+
231
+ /**
232
+ * MCP 开关选择器。
233
+ *
234
+ * 同一个组件用于两处:
235
+ * - `conversation.session.header.utilities`(有会话时的标题栏工具区);
236
+ * - `conversation.hero.options`(新会话落地页那一行)。
237
+ *
238
+ * 后者是必需的:新会话页的会话页头带 `hideChrome`,整个 header 都不渲染,
239
+ * 只挂标题栏会让按钮在新会话页上消失。两处都由槽提供 `sessionId`,因此
240
+ * 开关始终绑定在同一个会话上 —— 在新会话页关掉的服务器,进入对话后依然关着。
241
+ * @param props 会话标准 props、`t`,以及可选的 `compact`。
242
+ */
243
+ function McpPicker({ sessionId, t, compact }) {
244
+ const [open, setOpen] = React.useState(false)
245
+ const [state, setState] = React.useState({ phase: 'loading' })
246
+ const [attempt, setAttempt] = React.useState(0)
247
+ const [busy, setBusy] = React.useState(false)
248
+ const [error, setError] = React.useState()
249
+ const [notice, setNotice] = React.useState()
250
+ const rootRef = React.useRef(null)
251
+
252
+ // 开关按会话 id 保存;槽还没给出会话时没有可写的键。
253
+ const scoped = sessionId !== undefined && sessionId !== null && String(sessionId) !== ''
254
+ const sessionKey = scoped ? String(sessionId) : ''
255
+
256
+ // 读当前会话的开关快照:首次渲染与每次重试都读。
257
+ React.useEffect(() => {
258
+ if (!scoped) return undefined
259
+ const controller = new AbortController()
260
+ api(`/state?session=${encodeURIComponent(sessionKey)}`, { signal: controller.signal })
261
+ .then((payload) => {
262
+ if (controller.signal.aborted) return
263
+ setState({ phase: 'ready', data: payload })
264
+ }, (reason) => {
265
+ if (controller.signal.aborted) return
266
+ setState({ phase: 'failed', message: reason instanceof Error ? reason.message : String(reason) })
267
+ })
268
+ return () => { controller.abort() }
269
+ }, [scoped, sessionKey, attempt])
270
+
271
+ // 工具集合可能在本插件之外变化(服务器重连);打开时刷新一次。
272
+ React.useEffect(() => {
273
+ if (!open || !scoped) return undefined
274
+ const controller = new AbortController()
275
+ api(`/state?session=${encodeURIComponent(sessionKey)}`, { signal: controller.signal })
276
+ .then((payload) => {
277
+ if (controller.signal.aborted) return
278
+ setState({ phase: 'ready', data: payload })
279
+ }, () => {})
280
+ return () => { controller.abort() }
281
+ }, [open, scoped, sessionKey])
282
+
283
+ // 点击外部或 Esc 关闭。
284
+ React.useEffect(() => {
285
+ if (!open) return undefined
286
+ const onPointerDown = (event) => {
287
+ if (rootRef.current !== null && !rootRef.current.contains(event.target)) setOpen(false)
288
+ }
289
+ const onKeyDown = (event) => {
290
+ if (event.key === 'Escape') setOpen(false)
291
+ }
292
+ document.addEventListener('pointerdown', onPointerDown)
293
+ document.addEventListener('keydown', onKeyDown)
294
+ return () => {
295
+ document.removeEventListener('pointerdown', onPointerDown)
296
+ document.removeEventListener('keydown', onKeyDown)
297
+ }
298
+ }, [open])
299
+
300
+ const data = state.phase === 'ready' ? state.data : undefined
301
+ const servers = data?.servers ?? []
302
+ const enabledCount = data?.enabledCount ?? 0
303
+
304
+ const label = !scoped
305
+ ? t('picker.summary.pending')
306
+ : data === undefined
307
+ ? t('picker.loading')
308
+ : servers.length === 0
309
+ ? t('picker.empty')
310
+ : enabledCount === servers.length
311
+ ? t('picker.summary.all')
312
+ : enabledCount === 0
313
+ ? t('picker.summary.none')
314
+ : `${enabledCount}/${servers.length}`
315
+
316
+ const tone = servers.length > 0 && enabledCount === 0
317
+ ? 'none'
318
+ : servers.length > 0 && enabledCount < servers.length ? 'partial' : 'all'
319
+
320
+ /** 写入一个开关(`server` 省略表示整组)。 */
321
+ const toggle = async (server, enabled) => {
322
+ if (!scoped) return
323
+ setBusy(true)
324
+ setError(undefined)
325
+ setNotice(undefined)
326
+ try {
327
+ const payload = await api('/toggle', {
328
+ method: 'POST',
329
+ payload: { session: sessionKey, server, enabled },
330
+ })
331
+ setState({ phase: 'ready', data: payload })
332
+ if (payload.persisted === false) setNotice(t('picker.unsaved'))
333
+ } catch (reason) {
334
+ setError(reason instanceof Error ? reason.message : String(reason))
335
+ } finally {
336
+ setBusy(false)
337
+ }
338
+ }
339
+
340
+ /** 渲染一个服务器行。 */
341
+ const renderRow = (server) => {
342
+ const on = server.enabled === true
343
+ return React.createElement('div', {
344
+ key: server.name,
345
+ className: 'mt-item',
346
+ 'data-busy': String(busy),
347
+ },
348
+ React.createElement('div', { className: 'mt-body' },
349
+ React.createElement('div', { className: 'mt-title' },
350
+ server.name,
351
+ server.connected !== true && React.createElement('span', {
352
+ className: 'mt-badge', 'data-tone': 'warn',
353
+ }, t('picker.disconnected'))),
354
+ React.createElement('div', { className: 'mt-sub', title: server.target },
355
+ `${server.tools} ${t('picker.tools')}${server.target === '' ? '' : ` · ${server.target}`}`)),
356
+ React.createElement('button', {
357
+ type: 'button',
358
+ className: 'mt-switch',
359
+ role: 'switch',
360
+ 'aria-checked': on,
361
+ 'aria-label': server.name,
362
+ disabled: busy,
363
+ onClick: () => { void toggle(server.name, !on) },
364
+ }))
365
+ }
366
+
367
+ return React.createElement('div', { className: 'mt-wrap', ref: rootRef },
368
+ React.createElement('style', null, STYLE),
369
+ React.createElement('button', {
370
+ type: 'button',
371
+ className: 'mt-trigger',
372
+ 'data-open': String(open),
373
+ 'data-partial': String(tone === 'partial'),
374
+ 'data-none': String(tone === 'none'),
375
+ 'data-compact': String(compact === true),
376
+ title: `${t('picker.title')} — ${label}`,
377
+ 'aria-haspopup': 'dialog',
378
+ 'aria-expanded': open,
379
+ onClick: () => { setOpen((value) => !value) },
380
+ },
381
+ React.createElement('span', { className: 'mt-icon' }, React.createElement(PlugGlyph, { size: 13 })),
382
+ React.createElement('span', { className: 'mt-name' }, 'MCP'),
383
+ React.createElement('span', {
384
+ className: 'mt-badge',
385
+ 'data-tone': tone === 'none' ? 'warn' : undefined,
386
+ }, label),
387
+ React.createElement('span', { className: 'mt-caret' }, React.createElement(Caret))),
388
+
389
+ open && React.createElement('div', {
390
+ className: 'mt-menu',
391
+ role: 'dialog',
392
+ 'aria-label': t('picker.title'),
393
+ },
394
+ React.createElement('div', { className: 'mt-head' },
395
+ React.createElement('span', null, scoped ? t('picker.scope') : t('picker.scopeDefault')),
396
+ React.createElement('span', { className: 'mt-headActions' },
397
+ React.createElement('span', {
398
+ className: 'mt-link',
399
+ role: 'button',
400
+ tabIndex: 0,
401
+ 'data-disabled': String(!scoped || busy),
402
+ onClick: () => { if (scoped && !busy) void toggle(null, true) },
403
+ onKeyDown: (event) => {
404
+ if (event.key === 'Enter' && scoped && !busy) void toggle(null, true)
405
+ },
406
+ }, t('picker.allOn')),
407
+ React.createElement('span', {
408
+ className: 'mt-link',
409
+ role: 'button',
410
+ tabIndex: 0,
411
+ 'data-disabled': String(!scoped || busy),
412
+ onClick: () => { if (scoped && !busy) void toggle(null, false) },
413
+ onKeyDown: (event) => {
414
+ if (event.key === 'Enter' && scoped && !busy) void toggle(null, false)
415
+ },
416
+ }, t('picker.allOff')))),
417
+
418
+ !scoped && React.createElement('div', { className: 'mt-foot' }, t('picker.scopeDefault')),
419
+ scoped && state.phase === 'loading'
420
+ && React.createElement('div', { className: 'mt-foot' }, t('picker.loading')),
421
+ state.phase === 'failed' && React.createElement('div', { className: 'mt-err' },
422
+ `${t('picker.failed')}: ${state.message}`,
423
+ React.createElement('button', {
424
+ type: 'button',
425
+ className: 'mt-btn',
426
+ style: { marginLeft: 8 },
427
+ onClick: () => {
428
+ setState({ phase: 'loading' })
429
+ setAttempt((value) => value + 1)
430
+ },
431
+ }, t('picker.retry'))),
432
+
433
+ data !== undefined && servers.length === 0 && React.createElement('div', { className: 'mt-foot' },
434
+ t('picker.emptyHint')),
435
+
436
+ servers.map(renderRow),
437
+
438
+ error !== undefined && React.createElement('div', { className: 'mt-err' }, error),
439
+ notice !== undefined && React.createElement('div', { className: 'mt-foot' }, notice),
440
+ busy && React.createElement('div', { className: 'mt-foot' }, t('picker.saving'))))
441
+ }
442
+
443
+ // #endregion
444
+
445
+ // #region 插件
446
+
447
+ return {
448
+ inject: ['slots', 'locale'],
449
+ apply(ctx) {
450
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'mcp-toggles: dictionaries')
451
+
452
+ // 会话标题栏右侧工具区:选择器天然绑定当前会话。
453
+ ctx.effect(() => ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
454
+ name: 'conversation.session.header.utilities',
455
+ id: 'mcp-toggles',
456
+ order: -7,
457
+ locale: NS,
458
+ }, McpPicker)), 'mcp-toggles: header picker')
459
+
460
+ // 新会话落地页那一行。必需:该页的会话页头整体带 `hideChrome`
461
+ // (`blank && composerPhase === 'blank'`),只挂标题栏会让按钮在
462
+ // 新会话页上完全消失。
463
+ ctx.effect(() => ctx.slots.inject('conversation.hero.options', () => ctx.slots.register({
464
+ name: 'conversation.hero.options',
465
+ id: 'mcp-toggles-hero',
466
+ order: 20,
467
+ locale: NS,
468
+ }, (props) => React.createElement(McpPicker, { ...props, compact: true }))),
469
+ 'mcp-toggles: hero picker')
470
+ },
471
+ }
472
+ },
473
+ })
package/lib/index.js ADDED
@@ -0,0 +1,560 @@
1
+ /**
2
+ * dsh-plugin-mcp-toggles — 让每个会话单独决定哪些 MCP 服务器对模型可见。
3
+ *
4
+ * ## 为什么需要它
5
+ * MCP 服务器在 profile 里是**进程级**的:`@deepseek-ai/dsh-mcp-client` 每行
6
+ * 连一个服务器,并把 `mcp__<serverName>__<tool>` 注册到**全局**工具层。于是
7
+ * 每个会话都看得到同一批 MCP 工具 —— 想在一个会话里只用 REA、在另一个会话
8
+ * 里只用别的服务器,原生做不到。
9
+ *
10
+ * ## 实际使用的机制
11
+ * `agent.ctx.tools.restrict({ deny })` 是作用域内生效的**全局工具掩码**
12
+ * (见 `packages/core/tools/src/index.ts` 的 `restrict`):只影响该 agent
13
+ * 的可见工具集,schema 投影、查找与派发都走同一个解析器,因此模型既看不到
14
+ * 也调不到被关掉的工具,而其他会话完全不受影响。
15
+ *
16
+ * 每个会话的开关存在 `$DSH_HOME/mcp-toggles-state.json`:键是 sessionId,
17
+ * 值是 `{ [serverName]: boolean }`。未记录即视为开启 —— 装上插件不会悄悄
18
+ * 关掉任何人的 MCP。
19
+ *
20
+ * @module dsh-plugin-mcp-toggles
21
+ */
22
+
23
+ import { randomBytes } from 'node:crypto'
24
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
25
+ import { homedir } from 'node:os'
26
+ import { dirname, join } from 'node:path'
27
+
28
+ export const name = 'mcp-toggles'
29
+ export const inject = ['webServer']
30
+
31
+ // #region 常量
32
+
33
+ /** API 前缀(与客户端半的常量保持一致)。 */
34
+ const API_PREFIX = '/mcp-toggles/api'
35
+
36
+ /** 页面 meta 名:客户端据此拿到同源令牌。 */
37
+ const TOKEN_META_NAME = 'dsh-mcp-toggles'
38
+
39
+ /** 请求头名。 */
40
+ const TOKEN_HEADER = 'x-mcp-toggles-token'
41
+
42
+ /** 请求体上限。 */
43
+ const MAX_BODY_BYTES = 32 * 1024
44
+
45
+ /** mcp-client 的包名:`configEditor.entries()` 里它就是 MCP 行。 */
46
+ const MCP_CLIENT_PACKAGE = '@deepseek-ai/dsh-mcp-client'
47
+
48
+ /** 由 `@deepseek-ai/dsh-mcp-resources` 注册的共享资源工具。 */
49
+ const SHARED_RESOURCE_TOOLS = ['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource']
50
+
51
+ /** 工具名前缀 / 分隔:`mcp__<serverName>__<rawName>`。 */
52
+ const PUBLIC_PREFIX = 'mcp__'
53
+
54
+ /** 从 `mcp__<server>__<tool>` 里取回服务器名的兜底解析。 */
55
+ const FALLBACK_PARSE = /^mcp__(.+?)__(.+)$/
56
+
57
+ /**
58
+ * 进程级掩码登记表的键。
59
+ *
60
+ * Hot reload 会重新求值本模块,模块级的 Map 随之丢掉,而 `restrict()` 装下的
61
+ * 掩码属于 agent 作用域、并不随模块销毁 —— 旧 disposer 一旦丢失,那层掩码就
62
+ * 永久生效,表现为「某些会话突然看不到 MCP 工具且开关无效」。因此登记表放在
63
+ * 全局,新模块直接接手上一代留下的记录并可以正常撤销。
64
+ */
65
+ const MASKS_KEY = Symbol.for('dsh-plugin-mcp-toggles.masks')
66
+
67
+ // #endregion
68
+
69
+ // #region 配置
70
+
71
+ /**
72
+ * 解析插件配置。
73
+ * @param config 来自 `cordis.patch.yml` 的原始配置。
74
+ * @returns 归一化后的配置。
75
+ */
76
+ export function resolveConfig(config = {}) {
77
+ return {
78
+ /**
79
+ * 共享的 MCP 资源工具(`list_mcp_resources` 等)是否跟随整体开关:
80
+ * 只有当前会话关掉了**全部** MCP 服务器时才隐藏它们。
81
+ */
82
+ sharedResourceTools: config.sharedResourceTools !== false,
83
+ /** 额外需要识别为 MCP 服务器的名字(服务器暂时没有工具时也能列出)。 */
84
+ extraServers: Array.isArray(config.extraServers)
85
+ ? config.extraServers.filter((name) => typeof name === 'string' && name.length > 0)
86
+ : [],
87
+ }
88
+ }
89
+
90
+ // #endregion
91
+
92
+ // #region 选择存储
93
+
94
+ /** 状态文件。 */
95
+ function stateFile() {
96
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
97
+ return join(home, 'mcp-toggles-state.json')
98
+ }
99
+
100
+ /** 读取持久化的每会话开关;坏文件退回空状态,不影响使用。 */
101
+ function loadState() {
102
+ try {
103
+ const parsed = JSON.parse(readFileSync(stateFile(), 'utf8'))
104
+ const bySession = parsed?.bySession
105
+ if (typeof bySession !== 'object' || bySession === null) return { bySession: {} }
106
+ /** @type {Record<string, Record<string, boolean>>} */
107
+ const clean = {}
108
+ for (const [sessionId, entry] of Object.entries(bySession)) {
109
+ if (typeof entry !== 'object' || entry === null) continue
110
+ const row = {}
111
+ for (const [server, enabled] of Object.entries(entry)) {
112
+ if (typeof enabled === 'boolean') row[server] = enabled
113
+ }
114
+ clean[sessionId] = row
115
+ }
116
+ return { bySession: clean }
117
+ } catch {
118
+ return { bySession: {} }
119
+ }
120
+ }
121
+
122
+ /** 写回持久化开关;失败只影响下次启动,不影响本次运行。 */
123
+ function saveState(state) {
124
+ try {
125
+ mkdirSync(dirname(stateFile()), { recursive: true })
126
+ writeFileSync(stateFile(), JSON.stringify(state, null, 2), 'utf8')
127
+ return true
128
+ } catch {
129
+ return false
130
+ }
131
+ }
132
+
133
+ // #endregion
134
+
135
+ // #region 插件
136
+
137
+ /**
138
+ * 注册路由,并按会话装配 MCP 工具掩码。
139
+ * @param ctx 插件上下文。
140
+ * @param rawConfig 行配置。
141
+ */
142
+ export function apply(ctx, rawConfig = {}) {
143
+ const config = resolveConfig(rawConfig)
144
+ const log = ctx.logger?.('mcp-toggles') ?? console
145
+ // 每次加载生成一枚随机令牌,注入页面 meta,客户端回传它才允许改开关。
146
+ // 用加密随机源而非 Math.random:这枚令牌是唯一挡住跨站改写的东西。
147
+ const token = randomBytes(32).toString('hex')
148
+ const state = loadState()
149
+
150
+ /**
151
+ * 每个会话当前已装的掩码:`key` 是生效的 deny 指纹,用于幂等判断
152
+ * (`restrict` 会触发 `tools/change`,指纹相同就不再重装,避免自激)。
153
+ *
154
+ * 跨模块代际共享(见 {@link MASKS_KEY}):热重载后新模块接手旧记录,也就能
155
+ * 撤销旧一代装下的掩码,不会留下永久生效的孤儿。
156
+ * @type {Map<string, { key: string, dispose?: () => void }>}
157
+ */
158
+ const installed = globalThis[MASKS_KEY] ??= new Map()
159
+
160
+ /**
161
+ * 本代模块的身份。
162
+ *
163
+ * 插件热重载会先卸载旧一代:旧一代的 `ctx.effect` 随插件上下文销毁,掩码
164
+ * 已经撤销,但共享登记表里还留着旧记录。只比 deny 指纹会把这种**已失效**的
165
+ * 记录误当成现役状态,于是新会话看不到应有的工具掩码。因此记录带上代际,
166
+ * 只有本代亲手装的记录才算现役。
167
+ */
168
+ const generation = Symbol('mcp-toggles generation')
169
+
170
+ /** 本代模块亲手装过的会话;卸载时只清理这些,不误伤下一代接手的记录。 */
171
+ const mine = new Set()
172
+
173
+ // #region 服务器目录
174
+
175
+ /**
176
+ * 读 `configEditor` 里所有 MCP 行,拿到 serverName 与连接参数事实。
177
+ *
178
+ * 这一步只用于**列出**:即使某个服务器当前一个工具都没连上,用户也能在
179
+ * 选择器里看到它并预先关掉。`configEditor` 缺失或被禁用时返回空数组。
180
+ * @returns serverName → 行事实。
181
+ */
182
+ const configuredServers = () => {
183
+ /** @type {Map<string, { name: string, transport: string, target: string, rowId: string }>} */
184
+ const found = new Map()
185
+ try {
186
+ const editor = ctx.get('configEditor')
187
+ if (editor === undefined) return found
188
+ for (const entry of editor.entries()) {
189
+ if (entry?.options?.name !== MCP_CLIENT_PACKAGE) continue
190
+ const raw = entry.options.config
191
+ if (typeof raw !== 'object' || raw === null) continue
192
+ const serverName = typeof raw.serverName === 'string' ? raw.serverName : undefined
193
+ if (serverName === undefined || serverName.length === 0) continue
194
+ found.set(serverName, {
195
+ name: serverName,
196
+ transport: typeof raw.transport === 'string' ? raw.transport : 'stdio',
197
+ target: typeof raw.url === 'string'
198
+ ? raw.url
199
+ : typeof raw.command === 'string' ? raw.command : '',
200
+ rowId: typeof entry.options.id === 'string' ? entry.options.id : '',
201
+ })
202
+ }
203
+ } catch (error) {
204
+ // configEditor 可能因 profile 只读或版本差异不可用;列不出服务器并不
205
+ // 妨碍已连接服务器的开关,所以这里只记录一次。
206
+ log?.debug?.(`mcp-toggles: configEditor unavailable: ${String(error?.message ?? error)}`)
207
+ }
208
+ for (const serverName of config.extraServers) {
209
+ if (found.has(serverName)) continue
210
+ found.set(serverName, { name: serverName, transport: 'unknown', target: '', rowId: '' })
211
+ }
212
+ return found
213
+ }
214
+
215
+ /**
216
+ * 从全局工具视图里按服务器归组 MCP 工具。
217
+ *
218
+ * 优先用 `configEditor` 给的名字做前缀匹配(服务器名本身可能含 `__`),
219
+ * 再用 `mcp__<server>__<tool>` 兜底解析没匹配上的名字。
220
+ * @returns serverName → 公开工具名列表。
221
+ */
222
+ const toolsByServer = () => {
223
+ /** @type {Map<string, string[]>} */
224
+ const grouped = new Map()
225
+ const tools = ctx.get('tools')
226
+ if (tools === undefined) return grouped
227
+ let names = []
228
+ try {
229
+ names = tools.schemas().map((schema) => schema.name)
230
+ } catch (error) {
231
+ log?.warn?.(`mcp-toggles: cannot read global tool schemas: ${String(error?.message ?? error)}`)
232
+ return grouped
233
+ }
234
+ const known = [...configuredServers().keys()].sort((left, right) => right.length - left.length)
235
+ const push = (server, toolName) => {
236
+ const list = grouped.get(server)
237
+ if (list === undefined) grouped.set(server, [toolName])
238
+ else list.push(toolName)
239
+ }
240
+ for (const toolName of names) {
241
+ if (!toolName.startsWith(PUBLIC_PREFIX)) continue
242
+ const matched = known.find((server) => toolName.startsWith(`${PUBLIC_PREFIX}${server}__`))
243
+ if (matched !== undefined) {
244
+ push(matched, toolName)
245
+ continue
246
+ }
247
+ const parsed = FALLBACK_PARSE.exec(toolName)
248
+ if (parsed !== null) push(parsed[1], toolName)
249
+ }
250
+ for (const list of grouped.values()) list.sort()
251
+ return grouped
252
+ }
253
+
254
+ /**
255
+ * 当前进程里可开关的 MCP 服务器:已配置的行 ∪ 当前有工具的服务器。
256
+ * @returns 按名字排序的服务器目录。
257
+ */
258
+ const serverCatalog = () => {
259
+ const configured = configuredServers()
260
+ const grouped = toolsByServer()
261
+ const names = new Set([...configured.keys(), ...grouped.keys()])
262
+ return [...names].sort((left, right) => left.localeCompare(right)).map((serverName) => {
263
+ const row = configured.get(serverName)
264
+ return {
265
+ name: serverName,
266
+ transport: row?.transport ?? 'unknown',
267
+ target: row?.target ?? '',
268
+ tools: grouped.get(serverName) ?? [],
269
+ connected: grouped.has(serverName),
270
+ }
271
+ })
272
+ }
273
+
274
+ // #endregion
275
+
276
+ // #region 开关与掩码
277
+
278
+ /**
279
+ * 某会话里某个服务器是否开启;没有显式记录即视为开启。
280
+ * @param sessionId 会话 id。
281
+ * @param serverName 服务器名。
282
+ * @returns 是否对该会话可见。
283
+ */
284
+ const isEnabled = (sessionId, serverName) => state.bySession[sessionId]?.[serverName] !== false
285
+
286
+ /**
287
+ * 计算某会话应当被 deny 的全局工具名。
288
+ * @param sessionId 会话 id。
289
+ * @param catalog 一次性读到的服务器目录。
290
+ * @returns 排序后的工具名列表。
291
+ */
292
+ const denyFor = (sessionId, catalog) => {
293
+ const deny = []
294
+ for (const server of catalog) {
295
+ if (isEnabled(sessionId, server.name)) continue
296
+ deny.push(...server.tools)
297
+ }
298
+ if (config.sharedResourceTools && catalog.length > 0
299
+ && catalog.every(server => !isEnabled(sessionId, server.name))) {
300
+ deny.push(...SHARED_RESOURCE_TOOLS)
301
+ }
302
+ return deny.sort()
303
+ }
304
+
305
+ /** 撤掉某会话已装的掩码。 */
306
+ const clearSession = (sessionId) => {
307
+ const previous = installed.get(sessionId)
308
+ if (previous === undefined) return
309
+ installed.delete(sessionId)
310
+ try { previous.dispose?.() } catch {}
311
+ }
312
+
313
+ /**
314
+ * 为一个会话重算并装上掩码。**只在 {@link reconcile} 内部调用。**
315
+ *
316
+ * `restrict()` 与它返回的 disposer 都会**同步**发出 `tools/change`;若允许
317
+ * 在这两次调用之间重入,就会装出第二层掩码,随后被覆盖的记录再也撤销不掉
318
+ * —— 表现为「关掉能生效、再打开不生效」。{@link reconcile} 用一次性重试把
319
+ * 这种同步重入折叠成收尾的一轮,这里因此可以按「先摘旧、再装新」的直线顺序写。
320
+ *
321
+ * 掩码注册在 `agent.ctx` 上(`restrict` 只接受作用域上下文),但登记**持有者**
322
+ * 是本插件的 `ctx.effect`:作用域上下文与插件上下文都会在销毁时调用同一个
323
+ * disposer,因此插件热重载或会话销毁都不会留下抹不掉的掩码。
324
+ * @param agent 目标 agent。
325
+ * @param catalog 复用的服务器目录。
326
+ */
327
+ const applyFor = (agent, catalog) => {
328
+ const sessionId = String(agent.id)
329
+ const deny = denyFor(sessionId, catalog)
330
+ const key = deny.join('\n')
331
+ const previous = installed.get(sessionId)
332
+ // 只有本代装下的、指纹相同的记录才算已就位。
333
+ if (previous !== undefined && previous.generation === generation && previous.key === key) return
334
+ clearSession(sessionId)
335
+ if (deny.length === 0) return
336
+ const agentTools = agent.ctx?.tools
337
+ if (agentTools === undefined) return
338
+ const record = { generation, key, dispose: undefined }
339
+ installed.set(sessionId, record)
340
+ // 记录本代装过哪些会话:卸载时只清自己的记录,不动下一代已经接手的那批。
341
+ mine.add(sessionId)
342
+ try {
343
+ // 用插件自己的 ctx 包一层 effect,让掩码有第二个持有点。
344
+ record.dispose = ctx.effect(() => agentTools.restrict({ deny }), 'mcp-toggles: mask')
345
+ log?.debug?.(`mcp-toggles: session ${sessionId} hides ${deny.length} MCP tool(s)`)
346
+ } catch (error) {
347
+ // `restrict` 对未知名字抛错:服务器刚断线时工具可能在两次读取之间消失。
348
+ // 收尾那一轮会用新的名字集合重算,所以这里不放大成故障。
349
+ if (installed.get(sessionId) === record) installed.delete(sessionId)
350
+ mine.delete(sessionId)
351
+ log?.debug?.(`mcp-toggles: cannot install mask for ${sessionId}: ${String(error?.message ?? error)}`)
352
+ }
353
+ }
354
+
355
+ /** 正在对账;期间到来的变更折叠成收尾的一轮。 */
356
+ let reconciling = false
357
+ /** 对账期间是否又有变更要求重算。 */
358
+ let rescan = false
359
+
360
+ /**
361
+ * 对全部在线 agent 对账一次。
362
+ *
363
+ * 与 `tools/change` 的自激靠两层挡住:deny 指纹相同的会话直接跳过;期间的
364
+ * 同步重入只置 `rescan`,不嵌套执行,收尾时再跑一轮。
365
+ */
366
+ const reconcile = () => {
367
+ if (reconciling) {
368
+ rescan = true
369
+ return
370
+ }
371
+ reconciling = true
372
+ try {
373
+ do {
374
+ rescan = false
375
+ const catalog = serverCatalog()
376
+ const agents = ctx.get('agents')
377
+ for (const agent of agents?.list?.() ?? []) applyFor(agent, catalog)
378
+ } while (rescan)
379
+ } finally {
380
+ reconciling = false
381
+ }
382
+ }
383
+
384
+ // 新会话:装配它的掩码;已存在的会话在插件加载时补装。
385
+ ctx.on('agent/created', (payload) => {
386
+ if (payload?.agent !== undefined) reconcile()
387
+ })
388
+ ctx.on('agent/disposed', (payload) => {
389
+ const agent = payload?.agent
390
+ // 掩码注册在 agent 作用域上,随作用域一起销毁,这里只丢弃记录。
391
+ if (agent !== undefined) installed.delete(String(agent.id))
392
+ })
393
+ // 工具集合变化(服务器重连、工具列表更新):对账,指纹挡住自激。
394
+ ctx.on('tools/change', () => { reconcile() })
395
+
396
+ ctx.effect(() => {
397
+ reconcile()
398
+ return () => {
399
+ // 只清本代装过的会话:下一代模块在重载时已接手同一张表,误清会连带
400
+ // 撤销它刚装好的掩码。
401
+ for (const sessionId of [...mine]) clearSession(sessionId)
402
+ mine.clear()
403
+ }
404
+ }, 'mcp-toggles: per-session masks')
405
+
406
+ // #endregion
407
+
408
+ // #region 路由
409
+
410
+ const sendJson = (res, status, body) => {
411
+ const payload = JSON.stringify(body)
412
+ res.writeHead(status, {
413
+ 'content-type': 'application/json; charset=utf-8',
414
+ 'cache-control': 'no-store',
415
+ 'content-length': Buffer.byteLength(payload),
416
+ })
417
+ res.end(payload)
418
+ }
419
+
420
+ const readBody = async (req) => {
421
+ const chunks = []
422
+ let total = 0
423
+ for await (const chunk of req) {
424
+ total += chunk.length
425
+ if (total > MAX_BODY_BYTES) throw new Error('请求体过大')
426
+ chunks.push(chunk)
427
+ }
428
+ if (chunks.length === 0) return {}
429
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
430
+ }
431
+
432
+ /** 同源 + 令牌校验。 */
433
+ const authorize = (req) => {
434
+ if (req.headers[TOKEN_HEADER] !== token) return false
435
+ const site = req.headers['sec-fetch-site']
436
+ if (site !== undefined && site !== 'same-origin' && site !== 'none') return false
437
+ const origin = req.headers.origin
438
+ if (origin !== undefined) {
439
+ try {
440
+ if (new URL(origin).origin !== ctx.webServer.getOrigin()) return false
441
+ } catch {
442
+ return false
443
+ }
444
+ }
445
+ return true
446
+ }
447
+
448
+ /** 某会话的状态投影,供选择器渲染。 */
449
+ const snapshot = (sessionId) => {
450
+ const catalog = serverCatalog()
451
+ const servers = catalog.map(server => ({
452
+ name: server.name,
453
+ transport: server.transport,
454
+ target: server.target,
455
+ tools: server.tools.length,
456
+ connected: server.connected,
457
+ enabled: isEnabled(sessionId, server.name),
458
+ }))
459
+ return {
460
+ session: sessionId,
461
+ servers,
462
+ enabledCount: servers.filter(server => server.enabled).length,
463
+ sharedResourceTools: config.sharedResourceTools,
464
+ shadowedTools: denyFor(sessionId, catalog).length,
465
+ installed: installed.has(sessionId),
466
+ }
467
+ }
468
+
469
+ /** 写入某会话的一个开关,并对该会话立即生效。 */
470
+ const setSwitch = (sessionId, serverName, enabled) => {
471
+ const row = state.bySession[sessionId] ?? (state.bySession[sessionId] = {})
472
+ if (enabled) delete row[serverName]
473
+ else row[serverName] = false
474
+ if (Object.keys(row).length === 0) delete state.bySession[sessionId]
475
+ const persisted = saveState(state)
476
+ reconcile()
477
+ return persisted
478
+ }
479
+
480
+ ctx.effect(() => ctx.webServer.register({
481
+ kind: 'prefix',
482
+ path: API_PREFIX,
483
+ handler: async (req, res) => {
484
+ const url = new URL(req.url ?? '/', ctx.webServer.getOrigin())
485
+ const route = url.pathname.slice(API_PREFIX.length) || '/'
486
+
487
+ if (route === '/token' && req.method === 'GET') {
488
+ return sendJson(res, 200, { token })
489
+ }
490
+ if (!authorize(req)) {
491
+ return sendJson(res, 403, { ok: false, error: { code: 'forbidden', message: '未授权' } })
492
+ }
493
+
494
+ try {
495
+ if (route === '/state' && req.method === 'GET') {
496
+ const sessionId = url.searchParams.get('session')
497
+ if (sessionId === null || sessionId.length === 0) {
498
+ return sendJson(res, 400, { ok: false, error: { code: 'missing-session', message: '缺少会话 id' } })
499
+ }
500
+ return sendJson(res, 200, { ok: true, ...snapshot(sessionId) })
501
+ }
502
+
503
+ if (route === '/toggle' && req.method === 'POST') {
504
+ const body = await readBody(req)
505
+ const sessionId = typeof body.session === 'string' ? body.session : ''
506
+ if (sessionId.length === 0) {
507
+ return sendJson(res, 400, { ok: false, error: { code: 'missing-session', message: '缺少会话 id' } })
508
+ }
509
+ if (typeof body.enabled !== 'boolean') {
510
+ return sendJson(res, 400, { ok: false, error: { code: 'invalid-enabled', message: 'enabled 必须是布尔值' } })
511
+ }
512
+ // `server === null` 表示整组开/关。
513
+ if (body.server === null || body.server === undefined) {
514
+ const catalog = serverCatalog()
515
+ for (const server of catalog) {
516
+ const row = state.bySession[sessionId] ?? (state.bySession[sessionId] = {})
517
+ if (body.enabled) delete row[server.name]
518
+ else row[server.name] = false
519
+ }
520
+ if (state.bySession[sessionId] !== undefined
521
+ && Object.keys(state.bySession[sessionId]).length === 0) {
522
+ delete state.bySession[sessionId]
523
+ }
524
+ const persisted = saveState(state)
525
+ reconcile()
526
+ return sendJson(res, 200, { ok: true, persisted, ...snapshot(sessionId) })
527
+ }
528
+ if (typeof body.server !== 'string' || body.server.length === 0) {
529
+ return sendJson(res, 400, { ok: false, error: { code: 'invalid-server', message: 'server 必须是字符串' } })
530
+ }
531
+ const known = serverCatalog().some(server => server.name === body.server)
532
+ if (!known) {
533
+ return sendJson(res, 404, {
534
+ ok: false,
535
+ error: { code: 'unknown-server', message: `未安装的 MCP 服务器:${body.server}` },
536
+ })
537
+ }
538
+ const persisted = setSwitch(sessionId, body.server, body.enabled)
539
+ return sendJson(res, 200, { ok: true, persisted, ...snapshot(sessionId) })
540
+ }
541
+
542
+ return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '未知路由' } })
543
+ } catch (error) {
544
+ return sendJson(res, 500, { ok: false, error: { code: 'internal', message: String(error?.message ?? error) } })
545
+ }
546
+ },
547
+ }), 'mcp-toggles: routes')
548
+
549
+ // 页面早于插件加载时,客户端从 meta 拿令牌;插件的 tap 负责把它写进去。
550
+ ctx.effect(() => ctx.webServer.tapIndex((html) => {
551
+ if (html.includes(`name="${TOKEN_META_NAME}"`)) return html
552
+ const meta = `<meta name="${TOKEN_META_NAME}" content="${token}">`
553
+ const head = html.indexOf('</head>')
554
+ return head === -1 ? meta + html : html.slice(0, head) + meta + html.slice(head)
555
+ }), 'mcp-toggles: token meta')
556
+
557
+ // #endregion
558
+ }
559
+
560
+ // #endregion
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "MCP per conversation",
4
+ "description": "Adds a picker to each conversation header that lists the installed MCP servers and turns each one on or off for that conversation only."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "按会话开关 MCP",
4
+ "description": "在每个会话的标题栏添加一个选择按钮,列出已安装的 MCP 服务器,并可单独开启或关闭,只影响当前会话。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "dsh-plugin-mcp-toggles",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Turn installed MCP servers on or off per conversation, from a picker in the session header.",
6
+ "keywords": [
7
+ "dsh",
8
+ "deepseek-harness",
9
+ "dsh-plugin",
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "plugin"
13
+ ],
14
+ "homepage": "https://github.com/ZhaoHanshao/dsh-plugin-mcp-toggles#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ZhaoHanshao/dsh-plugin-mcp-toggles.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/ZhaoHanshao/dsh-plugin-mcp-toggles/issues"
21
+ },
22
+ "author": "ZhaoHanshao",
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": "^22.19.0 || >=24.0.0"
26
+ },
27
+ "main": "./lib/index.js",
28
+ "exports": {
29
+ ".": "./lib/index.js",
30
+ "./client": "./lib/client.js",
31
+ "./cordis.patch.yml": "./cordis.patch.yml",
32
+ "./locale/*.json": "./locale/*.json",
33
+ "./icon": "./icon.svg",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "lib/index.js",
38
+ "lib/client.js",
39
+ "cordis.patch.yml",
40
+ "locale/*.json",
41
+ "icon.svg"
42
+ ],
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ },
47
+ "client": {
48
+ "platform": "web",
49
+ "inject": [
50
+ "@deepseek-ai/dsh-client-ui-conversation"
51
+ ]
52
+ }
53
+ },
54
+ "peerDependencies": {
55
+ "@deepseek-ai/dsh-client-ui-conversation": ">=0.2.1-rc.3"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "@deepseek-ai/dsh-client-ui-conversation": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "scripts": {
63
+ "prepublishOnly": "node --check lib/index.js && node --check lib/client.js"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ }
68
+ }