dsh-plugin-mcp-ui 1.0.2
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 +91 -0
- package/client.js +335 -0
- package/cordis.patch.yml +5 -0
- package/index.js +479 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tofu-zyc (TOFU BOX)
|
|
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
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# dsh-plugin-mcp-ui
|
|
2
|
+
|
|
3
|
+
「设置 → MCP 服务器」页面插件。给 `@deepseek-ai/dsh-mcp-client` 补一个配置界面:
|
|
4
|
+
在网页上增删改 MCP 服务器,写入 profile 的 `cordis.patch.yml`,热生效。
|
|
5
|
+
|
|
6
|
+
官方 alpha(`0.1.6-alpha.1`)把 mcp-client 打包了但**一个服务器都不启用**,且
|
|
7
|
+
没有任何配置 UI——这个插件补的就是这一段。
|
|
8
|
+
|
|
9
|
+
## 功能
|
|
10
|
+
|
|
11
|
+
- 列表页:展示本插件管理的全部 MCP 服务器,支持启用/禁用、编辑、删除;
|
|
12
|
+
- 表单:`stdio`(command / args / env / cwd)与 `streamable-http`(url / headers)
|
|
13
|
+
两种传输,外加超时、断线重连、`failOnStartupError`;
|
|
14
|
+
- 只读展示 patch 里**其它来源**的 mcp-client 行(手写或其它 patch 带入的),
|
|
15
|
+
本页不会碰它们;
|
|
16
|
+
- 校验对齐 dsh-mcp-client 的 Config schema(serverName 字符集/长度、url 前缀、
|
|
17
|
+
正整数超时、serverName 在注册范围内唯一)。
|
|
18
|
+
|
|
19
|
+
## 工作原理(重要)
|
|
20
|
+
|
|
21
|
+
**真实配置源是 profile 的 `cordis.patch.yml` 中的一个标记块**,不是数据库也不是
|
|
22
|
+
settings 命名空间:
|
|
23
|
+
|
|
24
|
+
```yaml
|
|
25
|
+
# >>> dsh-plugin-mcp-ui managed ... >>>
|
|
26
|
+
- insert:
|
|
27
|
+
- id: mcp-filesystem
|
|
28
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
29
|
+
config:
|
|
30
|
+
transport: stdio
|
|
31
|
+
serverName: "filesystem"
|
|
32
|
+
command: "npx"
|
|
33
|
+
args:
|
|
34
|
+
- "-y"
|
|
35
|
+
# <<< dsh-plugin-mcp-ui managed <<<
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- 块外内容**逐字保留**(字符串拼接 + 临时文件原子替换,不 YAML 重排整个文件);
|
|
39
|
+
- profile 开着 `patchReload: live`(web profile 默认如此)时,保存后 Cordis 热应用
|
|
40
|
+
补丁,**数秒内生效,无需重启**;否则重启 dsh 生效;
|
|
41
|
+
- 因此这个文件本身可以进 git,UI 写的内容和你手改的内容完全同构、可审计;
|
|
42
|
+
- 卸载本插件后标记块变成普通注释行包裹的合法配置,MCP 服务器照常加载,不会失联。
|
|
43
|
+
|
|
44
|
+
文件定位(`resolvePatchPath`,按顺序):
|
|
45
|
+
|
|
46
|
+
1. patch 行显式配置的 `config.patchPath`;
|
|
47
|
+
2. **运行时锚点 `ctx.baseUrl`**:dsh 把 bundle 层、profile 的
|
|
48
|
+
`cordis.patch.yml`、home 层、`--patch` 覆盖层全部当作同一个 include
|
|
49
|
+
(`<profileDir>/cordis.yml`)的 patch 传入,所以条目的 `ctx.baseUrl` 就是
|
|
50
|
+
profile 目录 —— `link:` 开发安装走的就是这条路,**无需任何额外配置**;
|
|
51
|
+
3. 安装位置推导 `<profileDir>/node_modules/dsh-plugin-mcp-ui/`(registry 安装)。
|
|
52
|
+
|
|
53
|
+
第 2 步只在目录确实是 profile(存在 `cordis.yml` 且 `package.json` 带
|
|
54
|
+
`dsh.profile`)时才采信;三步全落空时插件会带提示拒绝启动,不会写到错误的
|
|
55
|
+
文件里。真要手工指定时用**覆盖行**(无 `insert:`,靠 id 命中自注册行、
|
|
56
|
+
`name` 做安全校验),不要再 insert 造成同 id 双行:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
- id: mcp-ui
|
|
60
|
+
name: dsh-plugin-mcp-ui
|
|
61
|
+
config:
|
|
62
|
+
patchPath: 'C:/Users/you/.dsh/profiles/web/cordis.patch.yml'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 安装
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
dsh plugin --profile web add github:tofu-zyc/dsh-plugin-tofu-box#path:dsh-plugin-mcp-ui
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
刷新设置页即可看到「MCP 服务器」标签。本包自带 `cordis.patch.yml` 自注册
|
|
72
|
+
(`dsh.bundle.patch` 机制),无需手工 insert。
|
|
73
|
+
|
|
74
|
+
## 注意
|
|
75
|
+
|
|
76
|
+
- 工具命名为 `mcp__<serverName>__<工具名>`;`serverName` 在同一注册范围唯一,
|
|
77
|
+
重复注册会抛错(页面会先校验拦下)。
|
|
78
|
+
- stdio 服务器启动慢/挂起可能短暂阻塞工具注册;`failOnStartupError` 默认关闭,
|
|
79
|
+
单个服务器坏了不会拖垮启动。
|
|
80
|
+
- env / headers 会以**明文**写进 `cordis.patch.yml`,注意 git 提交范围。
|
|
81
|
+
- MCP prompt 模板不被 dsh-mcp-client 支持,本页面也不提供。
|
|
82
|
+
|
|
83
|
+
## 与 DSH 版本的耦合点(0.1.6-alpha.1)
|
|
84
|
+
|
|
85
|
+
- 数据面走 `ctx.connection.rpc.intercept('/api', ...)`(宿主)×
|
|
86
|
+
`ctx.connection.rpc.call('/api', ...)`(浏览器),认证复用官方连接层;
|
|
87
|
+
旧的 `ctx.connection.api` 已在 0.1.2 移除,勿混淆。
|
|
88
|
+
- Config schema 对齐 `@deepseek-ai/dsh-mcp-client`:
|
|
89
|
+
`transport: stdio | streamable-http` 判别联合、`toolCallTimeoutMs` 默认 60000、
|
|
90
|
+
`reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }`。
|
|
91
|
+
- headless(无 connection 服务)时宿主半边只打一条日志,不注册 UI 通道。
|
package/client.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-plugin-mcp-ui",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
const React = require("react");
|
|
8
|
+
const { useState, useEffect, useCallback } = React;
|
|
9
|
+
|
|
10
|
+
const CSS = `
|
|
11
|
+
.mcpui{display:flex;flex-direction:column;gap:14px;padding:4px 0 24px;color:inherit}
|
|
12
|
+
.mcpui-head{display:flex;flex-direction:column;gap:4px}
|
|
13
|
+
.mcpui-title{font-size:15px;font-weight:600}
|
|
14
|
+
.mcpui-sub{font-size:12.5px;opacity:.72;line-height:1.5}
|
|
15
|
+
.mcpui-path{font-size:11.5px;opacity:.6;font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all}
|
|
16
|
+
.mcpui-warn{font-size:12px;line-height:1.55;padding:8px 12px;border-radius:8px;background:rgba(210,150,40,.14);border:1px solid rgba(210,150,40,.35)}
|
|
17
|
+
.mcpui-toast{position:sticky;top:8px;z-index:20;align-self:flex-start;padding:6px 12px;border-radius:8px;font-size:12.5px}
|
|
18
|
+
.mcpui-toast-ok{background:rgba(46,160,67,.18)}
|
|
19
|
+
.mcpui-toast-err{background:rgba(213,83,61,.18)}
|
|
20
|
+
.mcpui-state{padding:20px 8px;font-size:13px;opacity:.7}
|
|
21
|
+
.mcpui-error{color:#d5533d;opacity:1}
|
|
22
|
+
.mcpui-list{display:flex;flex-direction:column;gap:10px}
|
|
23
|
+
.mcpui-card{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid rgba(128,128,128,.28);border-radius:10px}
|
|
24
|
+
.mcpui-card-off{opacity:.55}
|
|
25
|
+
.mcpui-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
|
26
|
+
.mcpui-card-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
|
|
27
|
+
.mcpui-name{font-size:13.5px;font-weight:600}
|
|
28
|
+
.mcpui-chip{font-size:11px;padding:1px 8px;border-radius:999px;background:rgba(128,128,128,.16)}
|
|
29
|
+
.mcpui-chip-on{background:rgba(46,160,67,.22)}
|
|
30
|
+
.mcpui-chip-transport{background:rgba(80,140,255,.18)}
|
|
31
|
+
.mcpui-summary{font-size:12px;opacity:.72;font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all;line-height:1.45}
|
|
32
|
+
.mcpui-rowid{font-size:11px;opacity:.5;font-family:ui-monospace,SFMono-Regular,monospace}
|
|
33
|
+
.mcpui-actions{display:flex;align-items:center;gap:8px}
|
|
34
|
+
.mcpui-btn{font-size:12.5px;padding:5px 12px;border-radius:8px;border:1px solid rgba(128,128,128,.4);background:transparent;color:inherit;cursor:pointer}
|
|
35
|
+
.mcpui-btn:hover{border-color:rgba(80,140,255,.7)}
|
|
36
|
+
.mcpui-btn-primary{background:rgba(80,140,255,.22);border-color:rgba(80,140,255,.55)}
|
|
37
|
+
.mcpui-btn-danger{color:#d5533d;border-color:rgba(213,83,61,.45)}
|
|
38
|
+
.mcpui-btn:disabled{opacity:.5;cursor:not-allowed}
|
|
39
|
+
.mcpui-switch{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;cursor:pointer}
|
|
40
|
+
.mcpui-switch input{accent-color:rgb(80,140,255)}
|
|
41
|
+
.mcpui-form{display:flex;flex-direction:column;gap:10px;padding:14px;border:1px solid rgba(80,140,255,.45);border-radius:10px}
|
|
42
|
+
.mcpui-form-title{font-size:13px;font-weight:600}
|
|
43
|
+
.mcpui-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
|
|
44
|
+
.mcpui-field{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
45
|
+
.mcpui-field-wide{grid-column:1 / -1}
|
|
46
|
+
.mcpui-label{font-size:11px;opacity:.6}
|
|
47
|
+
.mcpui-hint{font-size:11px;opacity:.5;line-height:1.4}
|
|
48
|
+
.mcpui-input,.mcpui-select,.mcpui-textarea{font-size:12.5px;padding:6px 8px;border-radius:7px;border:1px solid rgba(128,128,128,.4);background:transparent;color:inherit;width:100%;box-sizing:border-box;font-family:inherit}
|
|
49
|
+
.mcpui-textarea{font-family:ui-monospace,SFMono-Regular,monospace;resize:vertical;min-height:56px}
|
|
50
|
+
.mcpui-input:focus,.mcpui-select:focus,.mcpui-textarea:focus{outline:none;border-color:rgba(80,140,255,.7)}
|
|
51
|
+
.mcpui-field-error{font-size:11.5px;color:#d5533d}
|
|
52
|
+
.mcpui-ext{display:flex;flex-direction:column;gap:6px;padding:10px 12px;border:1px dashed rgba(128,128,128,.4);border-radius:10px;opacity:.75}
|
|
53
|
+
.mcpui-ext-title{font-size:12px;font-weight:600}
|
|
54
|
+
.mcpui-ext-item{font-size:12px;font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all}
|
|
55
|
+
.mcpui-form-actions{display:flex;gap:8px;justify-content:flex-end}
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
const TRANSPORTS = [
|
|
59
|
+
{ value: "stdio", label: "stdio(本机命令)" },
|
|
60
|
+
{ value: "streamable-http", label: "streamable-http(远程 URL)" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const EMPTY_FORM = {
|
|
64
|
+
id: "",
|
|
65
|
+
serverName: "",
|
|
66
|
+
transport: "stdio",
|
|
67
|
+
disabled: false,
|
|
68
|
+
command: "",
|
|
69
|
+
args: "",
|
|
70
|
+
env: "",
|
|
71
|
+
cwd: "",
|
|
72
|
+
url: "",
|
|
73
|
+
headers: "",
|
|
74
|
+
toolCallTimeoutMs: "",
|
|
75
|
+
failOnStartupError: false,
|
|
76
|
+
reconnect: true,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function entryToForm(entry) {
|
|
80
|
+
return {
|
|
81
|
+
id: entry.id ?? "",
|
|
82
|
+
serverName: entry.serverName ?? "",
|
|
83
|
+
transport: entry.transport ?? "stdio",
|
|
84
|
+
disabled: entry.disabled === true,
|
|
85
|
+
command: entry.command ?? "",
|
|
86
|
+
args: Array.isArray(entry.args) ? entry.args.join("\n") : "",
|
|
87
|
+
env: Object.entries(entry.env ?? {}).map(([k, v]) => `${k}=${v}`).join("\n"),
|
|
88
|
+
cwd: entry.cwd ?? "",
|
|
89
|
+
url: entry.url ?? "",
|
|
90
|
+
headers: Object.entries(entry.headers ?? {}).map(([k, v]) => `${k}=${v}`).join("\n"),
|
|
91
|
+
toolCallTimeoutMs: entry.toolCallTimeoutMs != null ? String(entry.toolCallTimeoutMs) : "",
|
|
92
|
+
failOnStartupError: entry.failOnStartupError === true,
|
|
93
|
+
reconnect: entry.reconnect !== false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function Switch({ checked, onChange, label, disabled }) {
|
|
98
|
+
return React.createElement("label", { className: "mcpui-switch" },
|
|
99
|
+
React.createElement("input", {
|
|
100
|
+
type: "checkbox", checked: !!checked, disabled: !!disabled,
|
|
101
|
+
onChange: (e) => onChange(e.target.checked),
|
|
102
|
+
}),
|
|
103
|
+
label,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function Field({ label, hint, error, wide, children }) {
|
|
108
|
+
return React.createElement("div", { className: "mcpui-field" + (wide ? " mcpui-field-wide" : "") },
|
|
109
|
+
React.createElement("span", { className: "mcpui-label" }, label),
|
|
110
|
+
children,
|
|
111
|
+
hint ? React.createElement("span", { className: "mcpui-hint" }, hint) : null,
|
|
112
|
+
error ? React.createElement("span", { className: "mcpui-field-error" }, error) : null,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ServerCard({ entry, onEdit, onDelete, onToggle, busy }) {
|
|
117
|
+
return React.createElement("div", { className: "mcpui-card" + (entry.disabled ? " mcpui-card-off" : "") },
|
|
118
|
+
React.createElement("div", { className: "mcpui-card-head" },
|
|
119
|
+
React.createElement("div", { className: "mcpui-card-title" },
|
|
120
|
+
React.createElement("span", { className: "mcpui-name" }, entry.serverName),
|
|
121
|
+
React.createElement("span", { className: "mcpui-chip mcpui-chip-transport" },
|
|
122
|
+
entry.transport === "stdio" ? "stdio" : "http"),
|
|
123
|
+
React.createElement("span", { className: "mcpui-chip" + (entry.disabled ? "" : " mcpui-chip-on") },
|
|
124
|
+
entry.disabled ? "已禁用" : "已启用"),
|
|
125
|
+
),
|
|
126
|
+
React.createElement("div", { className: "mcpui-actions" },
|
|
127
|
+
React.createElement(Switch, {
|
|
128
|
+
checked: !entry.disabled, disabled: busy,
|
|
129
|
+
onChange: (on) => onToggle(entry, on), label: "",
|
|
130
|
+
}),
|
|
131
|
+
React.createElement("button", { className: "mcpui-btn", disabled: busy, onClick: () => onEdit(entry) }, "编辑"),
|
|
132
|
+
React.createElement("button", { className: "mcpui-btn mcpui-btn-danger", disabled: busy, onClick: () => onDelete(entry) }, "删除"),
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
React.createElement("div", { className: "mcpui-summary" }, entry.summary ?? ""),
|
|
136
|
+
React.createElement("div", { className: "mcpui-rowid" }, `patch 行 id: ${entry.id}`),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function ServerForm({ initial, originalId, errors, saving, onSave, onCancel }) {
|
|
141
|
+
const [form, setForm] = useState(initial);
|
|
142
|
+
const set = (patch) => setForm((f) => ({ ...f, ...patch }));
|
|
143
|
+
const input = (key, props) => React.createElement("input", Object.assign({
|
|
144
|
+
className: "mcpui-input", value: form[key] ?? "",
|
|
145
|
+
onChange: (e) => set({ [key]: e.target.value }),
|
|
146
|
+
}, props));
|
|
147
|
+
const textarea = (key, rows) => React.createElement("textarea", {
|
|
148
|
+
className: "mcpui-textarea", rows: rows ?? 3, value: form[key] ?? "",
|
|
149
|
+
onChange: (e) => set({ [key]: e.target.value }),
|
|
150
|
+
});
|
|
151
|
+
const isStdio = form.transport === "stdio";
|
|
152
|
+
return React.createElement("div", { className: "mcpui-form" },
|
|
153
|
+
React.createElement("div", { className: "mcpui-form-title" }, originalId ? `编辑服务器「${originalId}」` : "添加 MCP 服务器"),
|
|
154
|
+
React.createElement("div", { className: "mcpui-fields" },
|
|
155
|
+
Field({ label: "serverName", error: errors.serverName, hint: "工具将命名为 mcp__<serverName>__<工具名>;字母数字_-,1-32 位" },
|
|
156
|
+
input("serverName", { placeholder: "例如 filesystem" })),
|
|
157
|
+
Field({ label: "传输方式" },
|
|
158
|
+
React.createElement("select", {
|
|
159
|
+
className: "mcpui-select", value: form.transport,
|
|
160
|
+
onChange: (e) => set({ transport: e.target.value }),
|
|
161
|
+
}, TRANSPORTS.map((t) => React.createElement("option", { key: t.value, value: t.value }, t.label)))),
|
|
162
|
+
isStdio
|
|
163
|
+
? Field({ label: "command", error: errors.command, hint: "可执行文件,如 npx / uvx / node" }, input("command", { placeholder: "npx" }))
|
|
164
|
+
: Field({ label: "url", error: errors.url, hint: "http(s):// 开头的 MCP 端点" }, input("url", { placeholder: "https://example.com/mcp" })),
|
|
165
|
+
isStdio
|
|
166
|
+
? Field({ label: "args(每行一个参数)", wide: true }, textarea("args", 3))
|
|
167
|
+
: Field({ label: "headers(每行 KEY=VALUE)", wide: true, error: errors.headers, hint: "认证头写在这里,例如 Authorization=Bearer xxx" }, textarea("headers", 3)),
|
|
168
|
+
isStdio
|
|
169
|
+
? Field({ label: "env(每行 KEY=VALUE)", wide: true, error: errors.env, hint: "传给子进程的环境变量,例如 API_KEY=xxx" }, textarea("env", 3))
|
|
170
|
+
: null,
|
|
171
|
+
isStdio
|
|
172
|
+
? Field({ label: "cwd(可选工作目录)" }, input("cwd", { placeholder: "留空则使用默认目录" }))
|
|
173
|
+
: null,
|
|
174
|
+
Field({ label: "toolCallTimeoutMs(可选)", error: errors.toolCallTimeoutMs, hint: "单次工具调用超时,默认 60000" },
|
|
175
|
+
input("toolCallTimeoutMs", { placeholder: "60000", inputMode: "numeric" })),
|
|
176
|
+
Field({ label: "行 id(可选,留空自动生成)", error: errors.id },
|
|
177
|
+
input("id", { placeholder: "mcp-<serverName>" })),
|
|
178
|
+
),
|
|
179
|
+
React.createElement("div", { className: "mcpui-actions" },
|
|
180
|
+
Switch({ checked: !form.disabled, onChange: (on) => set({ disabled: !on }), label: "启用" }),
|
|
181
|
+
Switch({ checked: form.reconnect, onChange: (on) => set({ reconnect: on }), label: "断线自动重连" }),
|
|
182
|
+
Switch({ checked: form.failOnStartupError, onChange: (on) => set({ failOnStartupError: on }), label: "启动失败即报错(默认容忍)" }),
|
|
183
|
+
),
|
|
184
|
+
React.createElement("div", { className: "mcpui-form-actions" },
|
|
185
|
+
React.createElement("button", { className: "mcpui-btn", onClick: onCancel, disabled: saving }, "取消"),
|
|
186
|
+
React.createElement("button", {
|
|
187
|
+
className: "mcpui-btn mcpui-btn-primary", disabled: saving,
|
|
188
|
+
onClick: () => onSave({ ...form, ...(isStdio ? { url: "", headers: "" } : { command: "", args: "", env: "", cwd: "" }) }),
|
|
189
|
+
}, saving ? "保存中…" : "保存"),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function McpUiPage({ call }) {
|
|
195
|
+
const [state, setState] = useState({ loading: true, error: "", data: null });
|
|
196
|
+
const [form, setForm] = useState(null); // { form, originalId } | null
|
|
197
|
+
const [errors, setErrors] = useState({});
|
|
198
|
+
const [saving, setSaving] = useState(false);
|
|
199
|
+
const [busy, setBusy] = useState(false);
|
|
200
|
+
const [toast, setToast] = useState(null);
|
|
201
|
+
|
|
202
|
+
const refresh = useCallback(async () => {
|
|
203
|
+
try {
|
|
204
|
+
const data = await call("list");
|
|
205
|
+
setState({ loading: false, error: "", data });
|
|
206
|
+
} catch (error) {
|
|
207
|
+
setState((s) => ({ loading: false, error: error.message ?? String(error), data: s.data }));
|
|
208
|
+
}
|
|
209
|
+
}, [call]);
|
|
210
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
211
|
+
|
|
212
|
+
const notify = (kind, text) => setToast({ kind, text });
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (!toast) return;
|
|
215
|
+
const t = setTimeout(() => setToast(null), 4000);
|
|
216
|
+
return () => clearTimeout(t);
|
|
217
|
+
}, [toast]);
|
|
218
|
+
|
|
219
|
+
const guard = async (fn) => {
|
|
220
|
+
try { await fn(); return true; }
|
|
221
|
+
catch (error) {
|
|
222
|
+
setErrors(error.details ?? {});
|
|
223
|
+
notify("err", error.message ?? String(error));
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const onSave = async (formValues) => {
|
|
229
|
+
setSaving(true); setErrors({});
|
|
230
|
+
const changed = await guard(async () => {
|
|
231
|
+
await call("save", { entry: formValues, originalId: form?.originalId ?? "" });
|
|
232
|
+
});
|
|
233
|
+
setSaving(false);
|
|
234
|
+
if (!changed) return;
|
|
235
|
+
setForm(null);
|
|
236
|
+
notify("ok", "已写入 patch 文件;patchReload: live 下数秒内热生效");
|
|
237
|
+
void refresh();
|
|
238
|
+
};
|
|
239
|
+
const onDelete = (entry) => {
|
|
240
|
+
if (!window.confirm(`删除 MCP 服务器「${entry.serverName}」(patch 行 ${entry.id})?`)) return;
|
|
241
|
+
setBusy(true);
|
|
242
|
+
guard(async () => { await call("remove", { serverId: entry.id }); })
|
|
243
|
+
.then((changed) => { if (changed) notify("ok", "已删除"); void refresh(); })
|
|
244
|
+
.finally(() => setBusy(false));
|
|
245
|
+
};
|
|
246
|
+
const onToggle = (entry, on) => {
|
|
247
|
+
setBusy(true);
|
|
248
|
+
guard(async () => { await call("toggle", { serverId: entry.id, enabled: on }); })
|
|
249
|
+
.then(() => { notify("ok", on ? "已启用" : "已禁用"); void refresh(); })
|
|
250
|
+
.finally(() => setBusy(false));
|
|
251
|
+
};
|
|
252
|
+
const onEdit = (entry) => {
|
|
253
|
+
setErrors({});
|
|
254
|
+
setForm({ form: entryToForm(entry), originalId: entry.id });
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const data = state.data;
|
|
258
|
+
const startNew = () => { setErrors({}); setForm({ form: { ...EMPTY_FORM }, originalId: "" }); };
|
|
259
|
+
|
|
260
|
+
return React.createElement("div", { className: "mcpui" },
|
|
261
|
+
React.createElement("style", null, CSS),
|
|
262
|
+
toast ? React.createElement("div", { className: "mcpui-toast mcpui-toast-" + toast.kind }, toast.text) : null,
|
|
263
|
+
React.createElement("div", { className: "mcpui-head" },
|
|
264
|
+
React.createElement("div", { className: "mcpui-title" }, "MCP 服务器"),
|
|
265
|
+
React.createElement("div", { className: "mcpui-sub" },
|
|
266
|
+
"这里的增删改会写入 profile 的 cordis.patch.yml 标记块(块外内容原样保留),由 @deepseek-ai/dsh-mcp-client 逐行挂载。",
|
|
267
|
+
"profile 开启 patchReload: live 时保存后数秒热生效;否则重启 dsh 后生效。"),
|
|
268
|
+
data?.path ? React.createElement("div", { className: "mcpui-path" }, `管理文件:${data.path}`) : null,
|
|
269
|
+
),
|
|
270
|
+
React.createElement("div", { className: "mcpui-warn" },
|
|
271
|
+
"注意:stdio 服务器若启动缓慢或挂起,可能短暂阻塞工具注册;「启动失败即报错」保持关闭可避免拖垮启动。密码/Token 会以明文保存在 patch 文件中,请留意 git 提交范围。"),
|
|
272
|
+
form
|
|
273
|
+
? React.createElement(ServerForm, {
|
|
274
|
+
key: form.originalId || "__new__",
|
|
275
|
+
initial: form.form, originalId: form.originalId, errors, saving,
|
|
276
|
+
onSave, onCancel: () => { setForm(null); setErrors({}); },
|
|
277
|
+
})
|
|
278
|
+
: null,
|
|
279
|
+
state.loading && !data
|
|
280
|
+
? React.createElement("div", { className: "mcpui-state" }, "加载中…")
|
|
281
|
+
: null,
|
|
282
|
+
state.error
|
|
283
|
+
? React.createElement("div", { className: "mcpui-state mcpui-error" }, `读取失败:${state.error}`)
|
|
284
|
+
: null,
|
|
285
|
+
data
|
|
286
|
+
? React.createElement(React.Fragment, null,
|
|
287
|
+
React.createElement("div", { className: "mcpui-list" },
|
|
288
|
+
data.entries.length === 0 && !form
|
|
289
|
+
? React.createElement("div", { className: "mcpui-state" }, "暂无由本页管理的 MCP 服务器。")
|
|
290
|
+
: null,
|
|
291
|
+
data.entries.map((entry) => React.createElement(ServerCard, {
|
|
292
|
+
key: entry.id, entry, busy, onEdit, onDelete, onToggle,
|
|
293
|
+
}))),
|
|
294
|
+
data.external.length > 0
|
|
295
|
+
? React.createElement("div", { className: "mcpui-ext" },
|
|
296
|
+
React.createElement("div", { className: "mcpui-ext-title" }, "patch 中其它来源的 mcp-client 行(只读,本页不编辑)"),
|
|
297
|
+
data.external.map((row) => React.createElement("div", {
|
|
298
|
+
key: row.id, className: "mcpui-ext-item",
|
|
299
|
+
}, `${row.id} · ${row.serverName || "?"} · ${row.target || ""}`)))
|
|
300
|
+
: null,
|
|
301
|
+
!form
|
|
302
|
+
? React.createElement("div", { className: "mcpui-actions" },
|
|
303
|
+
React.createElement("button", { className: "mcpui-btn mcpui-btn-primary", onClick: startNew }, "添加 MCP 服务器"),
|
|
304
|
+
React.createElement("button", { className: "mcpui-btn", onClick: () => void refresh() }, "刷新"))
|
|
305
|
+
: null)
|
|
306
|
+
: null,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const inject = ["slots", "connection"];
|
|
311
|
+
|
|
312
|
+
function apply(ctx) {
|
|
313
|
+
// 走官方 typert 网关:/api/mcpUi/<method>,payload 形如 { args: {...} },
|
|
314
|
+
// 服务端是 dsh-plugin-mcp-ui 的 TypertRemoteService(SRC marker 发现)。
|
|
315
|
+
const call = async (method, args) => {
|
|
316
|
+
const response = await ctx.connection.rpc.call("/api", `mcpUi/${method}`, { args: args ?? {} });
|
|
317
|
+
if (!response.ok) {
|
|
318
|
+
const error = new Error(response.error?.message ?? "请求失败");
|
|
319
|
+
error.details = response.error?.details ?? {};
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
return response.value;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register(
|
|
326
|
+
{ name: "settings.section", id: "mcp-ui", order: 12, label: () => "MCP 服务器" },
|
|
327
|
+
() => React.createElement(McpUiPage, { call }),
|
|
328
|
+
));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
exports.apply = apply;
|
|
332
|
+
exports.inject = inject;
|
|
333
|
+
return module.exports;
|
|
334
|
+
},
|
|
335
|
+
});
|
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import z from '@deepseek-ai/schemastery'
|
|
5
|
+
import { TypertRemoteService, RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
|
6
|
+
|
|
7
|
+
export const name = 'dsh-plugin-mcp-ui'
|
|
8
|
+
|
|
9
|
+
export const Config = z.object({
|
|
10
|
+
// 被管理的 profile patch 文件。缺省时自动定位(见 resolvePatchPath):
|
|
11
|
+
// 1) 运行时锚点 ctx.baseUrl = profile 目录(dsh 每个顶层条目都挂在这棵
|
|
12
|
+
// include 树上,link: 开发安装也准);
|
|
13
|
+
// 2) 回退到安装位置 <profileDir>/node_modules/dsh-plugin-mcp-ui/(registry 安装)。
|
|
14
|
+
// 两者都拿不到时才需要显式配置(覆盖行,勿重复 insert)。
|
|
15
|
+
patchPath: z.string(),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const BEGIN = '# >>> dsh-plugin-mcp-ui managed (edits inside this block are owned by the settings UI) >>>'
|
|
19
|
+
const END = '# <<< dsh-plugin-mcp-ui managed <<<'
|
|
20
|
+
// profile 根配置文件名(dsh 的 include 锚点文件)与 patch 文件名。
|
|
21
|
+
const PROFILE_ROOT_FILENAME = 'cordis.yml'
|
|
22
|
+
const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
|
|
23
|
+
const MCP_ROW_NAME = '@deepseek-ai/dsh-mcp-client'
|
|
24
|
+
const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/
|
|
25
|
+
const ROW_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
|
26
|
+
const URL_RE = /^https?:\/\/\S+$/
|
|
27
|
+
// Remote 方法标记的原型属性名(@Remote() 装饰器的运行时等价物,见
|
|
28
|
+
// dsh-typert-protocol 的 addMarkerInitializer/mark)。
|
|
29
|
+
const REMOTE_METHODS_KEY = '@deepseek-ai/dsh-typert-protocol/remote-methods'
|
|
30
|
+
|
|
31
|
+
// ---- entry <-> YAML -------------------------------------------------------
|
|
32
|
+
// 块内容是本插件生成的确定性 YAML 子集:双引号标量(JSON 字符串是合法 YAML
|
|
33
|
+
// 双引号标量)、固定缩进。解析只针对自己写出的形状,永不解析用户区域。
|
|
34
|
+
|
|
35
|
+
function q(value) {
|
|
36
|
+
return JSON.stringify(String(value))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseScalar(raw) {
|
|
40
|
+
const text = raw.trim()
|
|
41
|
+
if (text.startsWith('"')) return JSON.parse(text)
|
|
42
|
+
if (text === 'true') return true
|
|
43
|
+
if (text === 'false') return false
|
|
44
|
+
if (text !== '' && !Number.isNaN(Number(text))) return Number(text)
|
|
45
|
+
return text
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function emitBlock(entries) {
|
|
49
|
+
const lines = [BEGIN]
|
|
50
|
+
if (entries.length > 0) {
|
|
51
|
+
lines.push('- insert:')
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
lines.push(` - id: ${entry.id}`)
|
|
54
|
+
lines.push(` name: '${MCP_ROW_NAME}'`)
|
|
55
|
+
if (entry.disabled === true) lines.push(' disabled: true')
|
|
56
|
+
lines.push(' config:')
|
|
57
|
+
lines.push(` transport: ${entry.transport}`)
|
|
58
|
+
lines.push(` serverName: ${q(entry.serverName)}`)
|
|
59
|
+
if (entry.transport === 'stdio') {
|
|
60
|
+
lines.push(` command: ${q(entry.command)}`)
|
|
61
|
+
if (Array.isArray(entry.args) && entry.args.length > 0) {
|
|
62
|
+
lines.push(' args:')
|
|
63
|
+
for (const arg of entry.args) lines.push(` - ${q(arg)}`)
|
|
64
|
+
}
|
|
65
|
+
if (entry.env && Object.keys(entry.env).length > 0) {
|
|
66
|
+
lines.push(' env:')
|
|
67
|
+
for (const [key, value] of Object.entries(entry.env)) lines.push(` ${q(key)}: ${q(value)}`)
|
|
68
|
+
}
|
|
69
|
+
if (typeof entry.cwd === 'string' && entry.cwd !== '') lines.push(` cwd: ${q(entry.cwd)}`)
|
|
70
|
+
} else {
|
|
71
|
+
lines.push(` url: ${q(entry.url)}`)
|
|
72
|
+
if (entry.headers && Object.keys(entry.headers).length > 0) {
|
|
73
|
+
lines.push(' headers:')
|
|
74
|
+
for (const [key, value] of Object.entries(entry.headers)) lines.push(` ${q(key)}: ${q(value)}`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (Number.isInteger(entry.toolCallTimeoutMs) && entry.toolCallTimeoutMs > 0) {
|
|
78
|
+
lines.push(` toolCallTimeoutMs: ${entry.toolCallTimeoutMs}`)
|
|
79
|
+
}
|
|
80
|
+
if (entry.failOnStartupError === true) lines.push(' failOnStartupError: true')
|
|
81
|
+
if (entry.reconnect === false) {
|
|
82
|
+
lines.push(' reconnect:')
|
|
83
|
+
lines.push(' enabled: false')
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
lines.push(END)
|
|
88
|
+
return lines.join('\n')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseBlock(body) {
|
|
92
|
+
const entries = []
|
|
93
|
+
let current = null
|
|
94
|
+
let mode = null // 'args' | 'env' | 'headers' | null
|
|
95
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
96
|
+
if (raw.trim() === '') continue
|
|
97
|
+
if (/^- insert:\s*$/.test(raw)) { mode = null; continue }
|
|
98
|
+
const row = raw.match(/^ {4}- id: (\S+)\s*$/)
|
|
99
|
+
if (row) {
|
|
100
|
+
current = { id: row[1], disabled: false }
|
|
101
|
+
entries.push(current)
|
|
102
|
+
mode = null
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
if (!current) continue
|
|
106
|
+
const rowKey = raw.match(/^ {6}(\S+): ?(.*)$/)
|
|
107
|
+
if (rowKey) {
|
|
108
|
+
if (rowKey[1] === 'disabled') current.disabled = parseScalar(rowKey[2]) === true
|
|
109
|
+
mode = null
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const cfgKey = raw.match(/^ {8}([A-Za-z][\w]*): ?(.*)$/)
|
|
113
|
+
if (cfgKey) {
|
|
114
|
+
const [, key, value] = cfgKey
|
|
115
|
+
if (value === '') {
|
|
116
|
+
if (key === 'args' || key === 'env' || key === 'headers') {
|
|
117
|
+
current[key] = key === 'args' ? [] : {}
|
|
118
|
+
mode = key
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
current[key] = parseScalar(value)
|
|
122
|
+
mode = null
|
|
123
|
+
}
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (mode === 'args') {
|
|
127
|
+
const item = raw.match(/^ {10}- (.+)$/)
|
|
128
|
+
if (item) { current.args.push(parseScalar(item[1])); continue }
|
|
129
|
+
} else if (mode === 'env' || mode === 'headers') {
|
|
130
|
+
const item = raw.match(/^ {10}"?([^":"]+)"?: (.+)$/)
|
|
131
|
+
if (item) { current[mode][parseScalar(item[1])] = parseScalar(item[2]); continue }
|
|
132
|
+
}
|
|
133
|
+
mode = null
|
|
134
|
+
}
|
|
135
|
+
return entries
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function splitFile(text) {
|
|
139
|
+
const begin = text.indexOf(BEGIN)
|
|
140
|
+
const end = text.indexOf(END)
|
|
141
|
+
if (begin === -1 || end === -1 || end < begin) {
|
|
142
|
+
return { head: pruneEmptyFlow(text.replace(/\s*$/, '')), managed: [], tail: '' }
|
|
143
|
+
}
|
|
144
|
+
const head = pruneEmptyFlow(text.slice(0, begin).replace(/\s*$/, ''))
|
|
145
|
+
const body = text.slice(begin + BEGIN.length, end)
|
|
146
|
+
const tail = pruneEmptyFlow(text.slice(end + END.length).replace(/^\s*\n?/, '').replace(/\s*$/, ''))
|
|
147
|
+
return { head, managed: parseBlock(body), tail }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// dsh 的 profile patch 模板自带一行空流数组 `[]`(仅此一行才有意义)。
|
|
151
|
+
// 管理块是块序列,若与 `[]` 并存会构成两个 YAML 文档节点 → 文件非法。
|
|
152
|
+
// 拼接前剔除这种独立成行的 `[]`;任何其他内容一律逐字保留。
|
|
153
|
+
function pruneEmptyFlow(text) {
|
|
154
|
+
if (!/^\[\s*\]$/m.test(text)) return text
|
|
155
|
+
return text
|
|
156
|
+
.split('\n')
|
|
157
|
+
.filter((line) => !/^\[\s*\]$/.test(line.trimEnd()))
|
|
158
|
+
.join('\n')
|
|
159
|
+
.replace(/\s*$/, '')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 是否含真实的 YAML 数组内容(非注释、非空行)。
|
|
163
|
+
function hasArrayContent(text) {
|
|
164
|
+
return text.split('\n').some((line) => {
|
|
165
|
+
const t = line.trim()
|
|
166
|
+
return t !== '' && !t.startsWith('#')
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function joinFile({ head, entries, tail }) {
|
|
171
|
+
const parts = []
|
|
172
|
+
if (head !== '') parts.push(head)
|
|
173
|
+
parts.push(emitBlock(entries))
|
|
174
|
+
if (tail !== '') parts.push(tail)
|
|
175
|
+
let out = `${parts.join('\n')}\n`
|
|
176
|
+
// 全文件(head + 标记块 + tail)若只剩注释,YAML 解析为 null,
|
|
177
|
+
// dsh 会拒绝整个 profile patch。补一行 `[]` 保证顶层是合法空数组。
|
|
178
|
+
if (entries.length === 0 && !hasArrayContent(head) && !hasArrayContent(tail)) {
|
|
179
|
+
out = `${out}[]\n`
|
|
180
|
+
}
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 只读扫描标记块之外的 mcp-client 行(用户手写 / 其它 patch 带来的),
|
|
185
|
+
// 仅供展示,UI 不编辑它们。
|
|
186
|
+
function scanExternal(text, managedIds) {
|
|
187
|
+
const begin = text.indexOf(BEGIN)
|
|
188
|
+
const end = text.indexOf(END)
|
|
189
|
+
const outside =
|
|
190
|
+
begin === -1 || end === -1 || end < begin
|
|
191
|
+
? text
|
|
192
|
+
: text.slice(0, begin) + '\n' + text.slice(end + END.length)
|
|
193
|
+
const external = []
|
|
194
|
+
const lines = outside.split(/\r?\n/)
|
|
195
|
+
let holder = null
|
|
196
|
+
const flush = () => {
|
|
197
|
+
if (holder && holder.name === MCP_ROW_NAME && holder.serverName !== '' && !managedIds.has(holder.id)) {
|
|
198
|
+
external.push({ id: holder.id, serverName: holder.serverName, target: holder.target })
|
|
199
|
+
}
|
|
200
|
+
holder = null
|
|
201
|
+
}
|
|
202
|
+
for (const line of lines) {
|
|
203
|
+
const id = line.match(/^\s*-? ?id: '?([\w-]+)'?\s*$/)
|
|
204
|
+
if (id) {
|
|
205
|
+
flush()
|
|
206
|
+
holder = { id: id[1], name: '', serverName: '', target: '' }
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (!holder) continue
|
|
210
|
+
if (/name: '?@deepseek-ai\/dsh-mcp-client'?/.test(line)) holder.name = MCP_ROW_NAME
|
|
211
|
+
const serverName = line.match(/^\s*serverName: "?([^"\s]+)"?\s*$/)
|
|
212
|
+
if (serverName && holder.name === MCP_ROW_NAME && holder.serverName === '') holder.serverName = serverName[1]
|
|
213
|
+
const target = line.match(/^\s*(command|url): "?([^"\s]+)"?\s*$/)
|
|
214
|
+
if (target && holder.name === MCP_ROW_NAME && holder.target === '') holder.target = target[2]
|
|
215
|
+
}
|
|
216
|
+
flush()
|
|
217
|
+
return external
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- 校验 / 规范化 ---------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
function parseKV(input) {
|
|
223
|
+
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
|
224
|
+
const direct = {}
|
|
225
|
+
for (const [key, value] of Object.entries(input)) direct[String(key)] = String(value ?? '')
|
|
226
|
+
return direct
|
|
227
|
+
}
|
|
228
|
+
const out = {}
|
|
229
|
+
for (const line of String(input ?? '').split(/\r?\n/)) {
|
|
230
|
+
const trimmed = line.trim()
|
|
231
|
+
if (trimmed === '') continue
|
|
232
|
+
const eq = trimmed.indexOf('=')
|
|
233
|
+
if (eq <= 0) throw new Error(`无法解析 "${line}":应为 KEY=VALUE 每行一条`)
|
|
234
|
+
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim()
|
|
235
|
+
}
|
|
236
|
+
return out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeEntry(input, existing) {
|
|
240
|
+
const errors = {}
|
|
241
|
+
const serverName = String(input.serverName ?? '').trim()
|
|
242
|
+
if (!SERVER_NAME_RE.test(serverName)) {
|
|
243
|
+
errors.serverName = 'serverName 只允许字母、数字、下划线、连字符,长度 1-32(与 dsh-mcp-client 约束一致)'
|
|
244
|
+
}
|
|
245
|
+
const transport = input.transport === 'streamable-http' ? 'streamable-http' : 'stdio'
|
|
246
|
+
const entry = {
|
|
247
|
+
id: String(input.id ?? '').trim() || `mcp-${serverName}`.toLowerCase(),
|
|
248
|
+
serverName,
|
|
249
|
+
transport,
|
|
250
|
+
disabled: input.disabled === true,
|
|
251
|
+
}
|
|
252
|
+
if (!ROW_ID_RE.test(entry.id)) errors.id = '行 id 需匹配 ^[a-z0-9][a-z0-9_-]{0,63}$'
|
|
253
|
+
if (transport === 'stdio') {
|
|
254
|
+
entry.command = String(input.command ?? '').trim()
|
|
255
|
+
if (entry.command === '') errors.command = 'stdio 传输必须填写 command'
|
|
256
|
+
const argsText = Array.isArray(input.args) ? input.args.join('\n') : String(input.args ?? '')
|
|
257
|
+
entry.args = argsText.split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim() !== '')
|
|
258
|
+
try {
|
|
259
|
+
entry.env = parseKV(input.env)
|
|
260
|
+
} catch (error) {
|
|
261
|
+
errors.env = error.message
|
|
262
|
+
}
|
|
263
|
+
entry.cwd = String(input.cwd ?? '').trim()
|
|
264
|
+
} else {
|
|
265
|
+
entry.url = String(input.url ?? '').trim()
|
|
266
|
+
if (!URL_RE.test(entry.url)) errors.url = 'streamable-http 传输必须填写 http(s):// 开头的 url'
|
|
267
|
+
try {
|
|
268
|
+
entry.headers = parseKV(input.headers)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
errors.headers = error.message
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (input.toolCallTimeoutMs !== undefined && input.toolCallTimeoutMs !== null && input.toolCallTimeoutMs !== '') {
|
|
274
|
+
const timeout = Number(input.toolCallTimeoutMs)
|
|
275
|
+
if (!Number.isInteger(timeout) || timeout <= 0) errors.toolCallTimeoutMs = '超时须为正整数(毫秒)'
|
|
276
|
+
else entry.toolCallTimeoutMs = timeout
|
|
277
|
+
}
|
|
278
|
+
entry.failOnStartupError = input.failOnStartupError === true
|
|
279
|
+
entry.reconnect = input.reconnect === false ? false : true
|
|
280
|
+
|
|
281
|
+
if (Object.keys(errors).length > 0) {
|
|
282
|
+
const error = new Error('表单校验未通过')
|
|
283
|
+
error.details = errors
|
|
284
|
+
throw error
|
|
285
|
+
}
|
|
286
|
+
const clash = existing.find((e) => e.serverName === serverName && e.id !== input.__keepId)
|
|
287
|
+
if (clash) {
|
|
288
|
+
const error = new Error(`serverName "${serverName}" 已被行 "${clash.id}" 占用(同一注册范围内必须唯一)`)
|
|
289
|
+
error.details = { serverName: error.message }
|
|
290
|
+
throw error
|
|
291
|
+
}
|
|
292
|
+
const idTaken = existing.some((e) => e.id === entry.id && e.id !== input.__keepId)
|
|
293
|
+
if (idTaken) {
|
|
294
|
+
const error = new Error(`patch 行 id "${entry.id}" 已存在,请换一个或先删除旧行`)
|
|
295
|
+
error.details = { id: error.message }
|
|
296
|
+
throw error
|
|
297
|
+
}
|
|
298
|
+
return entry
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function summarize(entry) {
|
|
302
|
+
if (entry.transport === 'stdio') {
|
|
303
|
+
return [entry.command, ...(entry.args ?? [])].join(' ')
|
|
304
|
+
}
|
|
305
|
+
return entry.url ?? ''
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function invalidError(error) {
|
|
309
|
+
if (error instanceof RemoteError) return error
|
|
310
|
+
const remote = new RemoteError('mcp-ui/invalid', error?.message ?? String(error), error?.details ?? {})
|
|
311
|
+
return remote
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- patch 文件读写 --------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
function resolvePatchPath(ctx, config) {
|
|
317
|
+
if (typeof config?.patchPath === 'string' && config.patchPath !== '') {
|
|
318
|
+
return path.isAbsolute(config.patchPath) ? config.patchPath : path.resolve(process.cwd(), config.patchPath)
|
|
319
|
+
}
|
|
320
|
+
const anchored = profileDirFromContext(ctx)
|
|
321
|
+
if (anchored !== '') return path.join(anchored, PROFILE_PATCH_FILENAME)
|
|
322
|
+
const here = fileURLToPath(import.meta.url)
|
|
323
|
+
// 回退:仅当本包物理位于 <profileDir>/node_modules/<pkg>/ 下时才按安装位置
|
|
324
|
+
// 推导;link:/junction 开发安装的 realpath 在仓库里,这里一定推不出来,
|
|
325
|
+
// 所以上面的运行时锚点才是主路径。
|
|
326
|
+
const pkgDir = path.dirname(here)
|
|
327
|
+
const nodeModulesDir = path.dirname(pkgDir)
|
|
328
|
+
const profileDir = path.dirname(nodeModulesDir)
|
|
329
|
+
if (
|
|
330
|
+
path.basename(pkgDir) === 'dsh-plugin-mcp-ui' &&
|
|
331
|
+
path.basename(nodeModulesDir) === 'node_modules' &&
|
|
332
|
+
existsSync(path.join(profileDir, 'node_modules')) // pnpm .pnpm 内层 node_modules 也在此被排除
|
|
333
|
+
) {
|
|
334
|
+
return path.join(profileDir, PROFILE_PATCH_FILENAME)
|
|
335
|
+
}
|
|
336
|
+
throw new Error(
|
|
337
|
+
'tofu-mcp-ui: 无法定位 profile 的 cordis.patch.yml:既没有从 ctx.baseUrl 拿到 ' +
|
|
338
|
+
'profile 目录(宿主未给出 include 锚点),也不是 node_modules 常规安装(开发安装/link 场景)。' +
|
|
339
|
+
'请在 patch 行上用覆盖行配置 config.patchPath,例如 ' +
|
|
340
|
+
"{ id: 'mcp-ui', name: 'dsh-plugin-mcp-ui', config: { patchPath: 'C:/Users/you/.dsh/profiles/web/cordis.patch.yml' } }",
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 只有真正的 profile 目录才认:cordis.yml 存在 + manifest 带 dsh.profile。
|
|
345
|
+
// 既校验锚点合法,也防止锚点意外退化成别处时把 MCP 行写进错误的文件。
|
|
346
|
+
function isProfileDir(dir) {
|
|
347
|
+
if (typeof dir !== 'string' || dir === '') return false
|
|
348
|
+
if (!existsSync(path.join(dir, PROFILE_ROOT_FILENAME))) return false
|
|
349
|
+
try {
|
|
350
|
+
const manifest = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'))
|
|
351
|
+
return Boolean(manifest && typeof manifest === 'object' && manifest.dsh?.profile)
|
|
352
|
+
} catch {
|
|
353
|
+
return false
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// dsh 把「bundle 层 + profile 的 cordis.patch.yml + home 层 + --patch 覆盖层」
|
|
358
|
+
// 全部当作同一个 include(<profileDir>/cordis.yml)的 patch 传入,于是每个顶层
|
|
359
|
+
// 条目的 ctx.baseUrl 就是 profile 目录的 file URL;插件 ctx 继承同一个 baseUrl。
|
|
360
|
+
// 这是 link: 开发安装下唯一可靠的锚点(安装位置推导会指向仓库)。
|
|
361
|
+
function profileDirFromContext(ctx) {
|
|
362
|
+
const baseUrl = ctx?.baseUrl
|
|
363
|
+
if (typeof baseUrl !== 'string' || baseUrl === '') return ''
|
|
364
|
+
try {
|
|
365
|
+
const url = new URL(baseUrl)
|
|
366
|
+
if (url.protocol !== 'file:') return ''
|
|
367
|
+
const dir = fileURLToPath(url)
|
|
368
|
+
return isProfileDir(dir) ? dir : ''
|
|
369
|
+
} catch {
|
|
370
|
+
return ''
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function readState(patchPath) {
|
|
375
|
+
const text = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
|
|
376
|
+
const { head, managed, tail } = splitFile(text)
|
|
377
|
+
return { head, entries: managed, tail, external: scanExternal(text, new Set(managed.map((e) => e.id))) }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 内容未变则不写盘(避免 patchReload: live 下的空转热重载)。
|
|
381
|
+
function writeEntries(patchPath, state, entries) {
|
|
382
|
+
const next = joinFile({ head: state.head, entries, tail: state.tail })
|
|
383
|
+
const current = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
|
|
384
|
+
if (next === current) return false
|
|
385
|
+
const tmp = `${patchPath}.mcp-ui.tmp`
|
|
386
|
+
writeFileSync(tmp, next, 'utf8')
|
|
387
|
+
renameSync(tmp, patchPath)
|
|
388
|
+
return true
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---- Remote 服务 ------------------------------------------------------------
|
|
392
|
+
// 浏览器经官方 typert 网关调用 `/api/mcpUi/<method>`(api-gateway 的 SRC
|
|
393
|
+
// marker 发现路径),认证、路由、错误编码全部复用官方设施;本插件不再
|
|
394
|
+
// 自建通道。方法返回值必须是 JSON-safe 普通对象;失败抛 RemoteError。
|
|
395
|
+
// 注意:网关按函数源码解析参数名,签名必须是唯一标识符、无解构/默认值/rest,
|
|
396
|
+
// 且避免与 typert lookup 参数(如 id)重名。
|
|
397
|
+
|
|
398
|
+
class McpUiService extends TypertRemoteService {
|
|
399
|
+
constructor(ctx, patchPath) {
|
|
400
|
+
super(ctx, 'mcpUi')
|
|
401
|
+
this.__patchPath = patchPath
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
list() {
|
|
405
|
+
const state = readState(this.__patchPath)
|
|
406
|
+
return {
|
|
407
|
+
path: this.__patchPath,
|
|
408
|
+
exists: existsSync(this.__patchPath),
|
|
409
|
+
entries: state.entries.map((e) => ({ ...e, summary: summarize(e) })),
|
|
410
|
+
external: state.external,
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
save(entry, originalId) {
|
|
415
|
+
try {
|
|
416
|
+
const state = readState(this.__patchPath)
|
|
417
|
+
const keepId = typeof originalId === 'string' ? originalId : ''
|
|
418
|
+
if (keepId !== '' && !state.entries.some((e) => e.id === keepId)) {
|
|
419
|
+
throw new Error(`要编辑的行 "${keepId}" 已不存在,请刷新列表`)
|
|
420
|
+
}
|
|
421
|
+
const rest = state.entries.filter((e) => e.id !== (keepId || entry?.id))
|
|
422
|
+
const normalized = normalizeEntry({ ...entry, __keepId: keepId }, state.entries)
|
|
423
|
+
const index = keepId === '' ? rest.length : state.entries.findIndex((e) => e.id === keepId)
|
|
424
|
+
const merged = [...rest]
|
|
425
|
+
merged.splice(Math.max(0, Math.min(index, merged.length)), 0, normalized)
|
|
426
|
+
const changed = writeEntries(this.__patchPath, state, merged)
|
|
427
|
+
return { changed, entry: { ...normalized, summary: summarize(normalized) } }
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw invalidError(error)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
remove(serverId) {
|
|
434
|
+
try {
|
|
435
|
+
const state = readState(this.__patchPath)
|
|
436
|
+
const id = String(serverId ?? '')
|
|
437
|
+
if (!state.entries.some((e) => e.id === id)) throw new Error(`行 "${id}" 不存在,请刷新列表`)
|
|
438
|
+
const changed = writeEntries(this.__patchPath, state, state.entries.filter((e) => e.id !== id))
|
|
439
|
+
return { changed }
|
|
440
|
+
} catch (error) {
|
|
441
|
+
throw invalidError(error)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
toggle(serverId, enabled) {
|
|
446
|
+
try {
|
|
447
|
+
const state = readState(this.__patchPath)
|
|
448
|
+
const id = String(serverId ?? '')
|
|
449
|
+
const index = state.entries.findIndex((e) => e.id === id)
|
|
450
|
+
if (index === -1) throw new Error(`行 "${id}" 不存在,请刷新列表`)
|
|
451
|
+
const entries = [...state.entries]
|
|
452
|
+
entries[index] = { ...entries[index], disabled: enabled === false }
|
|
453
|
+
const changed = writeEntries(this.__patchPath, state, entries)
|
|
454
|
+
return { changed, entry: entries[index] }
|
|
455
|
+
} catch (error) {
|
|
456
|
+
throw invalidError(error)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 等价于对四个方法各写一个 @Remote():在原型上登记 version-1 方法标记。
|
|
462
|
+
Object.defineProperty(McpUiService.prototype, REMOTE_METHODS_KEY, {
|
|
463
|
+
configurable: true,
|
|
464
|
+
value: Object.freeze({
|
|
465
|
+
version: 1,
|
|
466
|
+
methods: Object.freeze(
|
|
467
|
+
['list', 'save', 'remove', 'toggle'].map((method) =>
|
|
468
|
+
Object.freeze({ method, invocation: Object.freeze({ kind: 'direct' }) })),
|
|
469
|
+
),
|
|
470
|
+
}),
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
function apply(ctx, config) {
|
|
474
|
+
const patchPath = resolvePatchPath(ctx, config)
|
|
475
|
+
new McpUiService(ctx, patchPath)
|
|
476
|
+
ctx.logger.info(`tofu-mcp-ui: managing MCP servers in ${patchPath}`)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export { apply }
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-mcp-ui",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"keywords": ["dsh-plugin", "deepseek-harness", "dsh", "mcp", "settings", "ui"],
|
|
6
|
+
"publishConfig": { "registry": "https://registry.npmjs.org" },
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/tofu-zyc/dsh-plugin-tofu-box.git", "directory": "dsh-plugin-mcp-ui" },
|
|
8
|
+
"bugs": { "url": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/issues" },
|
|
9
|
+
"homepage": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/tree/main/dsh-plugin-mcp-ui",
|
|
10
|
+
"description": "在 Web 设置页管理 MCP 服务器:增删改查 stdio / streamable-http server,写入 profile patch 标记块并热生效。",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "index.js",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./client": "./client.js",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.6-alpha.1",
|
|
20
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"@deepseek-ai/dsh-typert-protocol": { "optional": true },
|
|
24
|
+
"@deepseek-ai/schemastery": { "optional": true }
|
|
25
|
+
},
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./cordis.patch.yml"
|
|
29
|
+
},
|
|
30
|
+
"client": {
|
|
31
|
+
"platform": "web",
|
|
32
|
+
"inject": [
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"client.js",
|
|
39
|
+
"index.js",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"README.md"
|
|
42
|
+
]
|
|
43
|
+
}
|