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 +21 -0
- package/README.md +110 -2
- package/cordis.patch.yml +5 -0
- package/icon.svg +7 -0
- package/lib/client.js +473 -0
- package/lib/index.js +560 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -4
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
|
-
#
|
|
1
|
+
# dsh-plugin-mcp-toggles
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-plugin-mcp-toggles)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](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 保存;会话被删除后对应记录会保留(无害,不会影响其他会话)。
|
package/cordis.patch.yml
ADDED
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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mcp-toggles",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|