dsh-mcp-plus 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 +149 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.iife.js +497 -0
- package/lib/index.mjs +328 -0
- package/package.json +62 -0
- package/scripts/install.mjs +105 -0
- package/src/client.ts +482 -0
- package/src/config.ts +108 -0
- package/src/dsh-mcp-plus.ts +197 -0
- package/src/http.ts +116 -0
- package/src/servers-store.ts +44 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// client.ts —— 浏览器侧 client 半入口
|
|
2
|
+
// P6 版:表格化的服务器管理 UI
|
|
3
|
+
//
|
|
4
|
+
// 三种状态(在同一个页内切换,不是 Tab):
|
|
5
|
+
// 列表(默认) — 表格行 + 「修改配置」按钮 + 顶部「+ 添加」+ 底部固定保存
|
|
6
|
+
// JSON — 顶部「关闭编辑」+ 大 JSON 文本域 + 底部固定保存
|
|
7
|
+
// 添加 — 弹出一个表单,简化字段(serverName/command/args/env/cwd)
|
|
8
|
+
//
|
|
9
|
+
// 数据模型:服务器列表存在于 UI 内存中,servers.json 是「持久化 + 真实状态」;
|
|
10
|
+
// 任何修改(开关/添加/编辑/JSON)先改 UI 内存,点底部「保存」才写盘 + 重连。
|
|
11
|
+
|
|
12
|
+
declare global {
|
|
13
|
+
interface Window {
|
|
14
|
+
__ModuleLoader__: {
|
|
15
|
+
load(info: {
|
|
16
|
+
id: string
|
|
17
|
+
factory: (
|
|
18
|
+
require: (mod: string) => unknown,
|
|
19
|
+
) => {
|
|
20
|
+
name: string
|
|
21
|
+
inject: string[]
|
|
22
|
+
apply: (ctx: unknown) => void
|
|
23
|
+
}
|
|
24
|
+
}): void
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 单台服务器的 UI 内部模型(status 来自 host 半:connecting / connected / disconnected / disabled) */
|
|
30
|
+
interface ServerRow {
|
|
31
|
+
serverName: string
|
|
32
|
+
command: string
|
|
33
|
+
args: string[]
|
|
34
|
+
env: Record<string, string>
|
|
35
|
+
cwd: string
|
|
36
|
+
enabled: boolean
|
|
37
|
+
status: 'connecting' | 'connected' | 'disconnected' | 'disabled'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeFactory(): (require: (mod: string) => unknown) => unknown {
|
|
41
|
+
return (require) => {
|
|
42
|
+
const React = require('react') as {
|
|
43
|
+
useState: <T>(init: T) => [T, (v: T | ((p: T) => T)) => void]
|
|
44
|
+
useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void
|
|
45
|
+
}
|
|
46
|
+
const { jsx: h } = require('react/jsx-runtime') as {
|
|
47
|
+
jsx: (type: string, props: Record<string, unknown>, key?: string) => unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const name = 'dsh-mcp-plus'
|
|
51
|
+
const inject: string[] = ['slots']
|
|
52
|
+
|
|
53
|
+
/** 共享样式 —— 用 DSH 语义 token,暗色主题自动适配 */
|
|
54
|
+
const styles = {
|
|
55
|
+
section: { padding: '16px 24px 80px', maxWidth: '720px', margin: '0 auto', position: 'relative' as const }, // 底部留位给 sticky 保存栏
|
|
56
|
+
desc: { fontSize: '13px', color: 'var(--dsw-alias-label-tertiary)', margin: '0 0 16px' },
|
|
57
|
+
btn: { padding: '8px 16px', cursor: 'pointer', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'var(--dsw-alias-label-primary)' },
|
|
58
|
+
btnPrimary: { padding: '8px 20px', cursor: 'pointer', borderRadius: '6px', border: '1px solid var(--dsw-alias-state-business-primary)', background: 'var(--dsw-alias-state-business-primary)', color: 'var(--dsw-alias-label-primary)', fontWeight: 600 as const },
|
|
59
|
+
row: {
|
|
60
|
+
display: 'flex', alignItems: 'center', gap: '12px',
|
|
61
|
+
padding: '10px 12px', marginBottom: '6px',
|
|
62
|
+
borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2)',
|
|
63
|
+
// 透明底:融入页面底色,只用边框分区(与官方设置页一致)
|
|
64
|
+
background: 'transparent',
|
|
65
|
+
},
|
|
66
|
+
rowCellName: { flex: '0 0 180px', fontWeight: 600 as const, fontSize: '13px', color: 'var(--dsw-alias-label-primary)', overflow: 'hidden' as const, textOverflow: 'ellipsis' as const, whiteSpace: 'nowrap' as const },
|
|
67
|
+
rowCellCmd: { flex: 1, fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)', fontFamily: 'monospace', overflow: 'hidden' as const, textOverflow: 'ellipsis' as const, whiteSpace: 'nowrap' as const },
|
|
68
|
+
rowActions: { display: 'flex', gap: '4px' },
|
|
69
|
+
iconBtn: { padding: '4px 8px', cursor: 'pointer', fontSize: '12px', borderRadius: '4px', border: 'none', background: 'transparent', color: 'var(--dsw-alias-label-tertiary)' },
|
|
70
|
+
stickyBar: {
|
|
71
|
+
position: 'sticky' as const,
|
|
72
|
+
bottom: 0,
|
|
73
|
+
padding: '12px 20px',
|
|
74
|
+
// 透明底与页面一致;sticky 悬浮时内容会从底下滚过,
|
|
75
|
+
// 用 backdrop-filter 模糊垫底保证可读性(现代浏览器均支持)
|
|
76
|
+
background: 'transparent',
|
|
77
|
+
backdropFilter: 'blur(8px)',
|
|
78
|
+
WebkitBackdropFilter: 'blur(8px)',
|
|
79
|
+
borderTop: '1px solid var(--dsw-alias-border-l2)',
|
|
80
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px',
|
|
81
|
+
// 居中容器内的整条栏:宽度 = 内容容器(maxWidth 720 - 左右 padding 48 = 672)
|
|
82
|
+
width: 'calc(100% - 48px)',
|
|
83
|
+
maxWidth: '672px',
|
|
84
|
+
margin: '24px auto 0',
|
|
85
|
+
borderRadius: '10px',
|
|
86
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
87
|
+
boxShadow: '0 2px 12px var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.12))',
|
|
88
|
+
},
|
|
89
|
+
stickySummary: { display: 'flex', flexDirection: 'column' as const, gap: '2px', minWidth: 0 },
|
|
90
|
+
stickyCount: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)', fontWeight: 600 as const },
|
|
91
|
+
stickyHint: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
92
|
+
msgErr: { fontSize: '13px', color: 'var(--dsw-alias-state-error-primary)' },
|
|
93
|
+
textarea: {
|
|
94
|
+
width: '100%', boxSizing: 'border-box' as const,
|
|
95
|
+
padding: '10px 12px', borderRadius: '8px',
|
|
96
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
97
|
+
background: 'transparent', color: 'var(--dsw-alias-label-primary)',
|
|
98
|
+
fontFamily: 'monospace', fontSize: '13px', lineHeight: '1.5',
|
|
99
|
+
// 高度:空配置时也有可用的编辑区;配置多时可拉到视口 60%
|
|
100
|
+
minHeight: '320px', maxHeight: '60vh',
|
|
101
|
+
resize: 'vertical' as const,
|
|
102
|
+
},
|
|
103
|
+
modalBackdrop: {
|
|
104
|
+
position: 'fixed' as const, top: 0, left: 0, right: 0, bottom: 0,
|
|
105
|
+
background: 'rgba(0,0,0,0.5)',
|
|
106
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
107
|
+
zIndex: 1000,
|
|
108
|
+
},
|
|
109
|
+
modalCard: {
|
|
110
|
+
background: 'var(--dsw-alias-bg-layer-1)', borderRadius: '12px', padding: '24px',
|
|
111
|
+
maxWidth: '480px', width: '90%', maxHeight: '80vh', overflowY: 'auto' as const,
|
|
112
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 复选框风格 */
|
|
117
|
+
function Checkbox(props: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
118
|
+
return h('input', {
|
|
119
|
+
type: 'checkbox',
|
|
120
|
+
checked: props.checked,
|
|
121
|
+
onChange: (e: { target: { checked: boolean } }) => props.onChange(e.target.checked),
|
|
122
|
+
style: { width: '16px', height: '16px', cursor: 'pointer' },
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 简短字段组件 */
|
|
127
|
+
function Field(props: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; textarea?: boolean }) {
|
|
128
|
+
return h('label', { style: { display: 'block', marginBottom: '12px' }, children: [
|
|
129
|
+
h('div', { style: { fontSize: '12px', marginBottom: '4px', color: 'var(--dsw-alias-label-secondary)' }, children: props.label }),
|
|
130
|
+
props.textarea
|
|
131
|
+
? h('textarea', { style: { ...styles.textarea, minHeight: '60px' }, value: props.value, placeholder: props.placeholder, onChange: (e: { target: { value: string } }) => props.onChange(e.target.value) })
|
|
132
|
+
: h('input', {
|
|
133
|
+
style: { width: '100%', boxSizing: 'border-box' as const, padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'var(--dsw-alias-label-primary)', fontFamily: 'monospace', fontSize: '13px' },
|
|
134
|
+
value: props.value, placeholder: props.placeholder,
|
|
135
|
+
onChange: (e: { target: { value: string } }) => props.onChange(e.target.value),
|
|
136
|
+
}),
|
|
137
|
+
] })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 「添加服务器」弹窗表单(简化字段,高级用法去 JSON 编辑器) */
|
|
141
|
+
function AddServerModal(props: { onClose: () => void; onAdd: (row: ServerRow) => void }) {
|
|
142
|
+
const [serverName, setServerName] = React.useState('')
|
|
143
|
+
const [command, setCommand] = React.useState('')
|
|
144
|
+
const [args, setArgs] = React.useState('')
|
|
145
|
+
const [env, setEnv] = React.useState('')
|
|
146
|
+
const [cwd, setCwd] = React.useState('')
|
|
147
|
+
const [err, setErr] = React.useState('')
|
|
148
|
+
|
|
149
|
+
const submit = () => {
|
|
150
|
+
// 简单校验:serverName(必填,合法字符) + command(必填)
|
|
151
|
+
if (!serverName.trim()) return setErr('请填写 serverName')
|
|
152
|
+
if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName.trim())) return setErr('serverName 只能是字母数字下划线连字符,1-32 字符')
|
|
153
|
+
if (!command.trim()) return setErr('请填写启动命令')
|
|
154
|
+
|
|
155
|
+
// 解析 env(每行 KEY=VALUE)和 args(空格分隔)
|
|
156
|
+
const envObj: Record<string, string> = {}
|
|
157
|
+
for (const line of env.split(/\r?\n/)) {
|
|
158
|
+
const t = line.trim()
|
|
159
|
+
if (!t) continue
|
|
160
|
+
const eq = t.indexOf('=')
|
|
161
|
+
if (eq > 0) envObj[t.slice(0, eq)] = t.slice(eq + 1)
|
|
162
|
+
}
|
|
163
|
+
props.onAdd({
|
|
164
|
+
serverName: serverName.trim(),
|
|
165
|
+
command: command.trim(),
|
|
166
|
+
args: args.split(/\s+/).filter(Boolean),
|
|
167
|
+
env: envObj,
|
|
168
|
+
cwd: cwd.trim(),
|
|
169
|
+
enabled: true,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return h('div', { style: styles.modalBackdrop, onClick: (e: { target: unknown; currentTarget: unknown }) => { if (e.target === e.currentTarget) props.onClose() }, children:
|
|
174
|
+
h('div', { style: styles.modalCard, children: [
|
|
175
|
+
h('h3', { style: { margin: '0 0 16px' }, children: '添加 MCP 服务器' }),
|
|
176
|
+
err && h('p', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', margin: '0 0 12px' }, children: err }),
|
|
177
|
+
h(Field, { label: '服务器名(唯一标识)', value: serverName, placeholder: 'my-server', onChange: setServerName }),
|
|
178
|
+
h(Field, { label: '启动命令', value: command, placeholder: 'C:/Program Files/nodejs/node.exe', onChange: setCommand }),
|
|
179
|
+
h(Field, { label: '参数(空格分隔)', value: args, placeholder: 'path/to/server.js stdio', onChange: setArgs }),
|
|
180
|
+
h(Field, { label: '环境变量(每行 KEY=VALUE)', value: env, placeholder: 'API_KEY=xxx', onChange: setEnv, textarea: true }),
|
|
181
|
+
h(Field, { label: '工作目录(可选)', value: cwd, placeholder: '', onChange: setCwd }),
|
|
182
|
+
h('div', { style: { display: 'flex', gap: '8px', marginTop: '12px', justifyContent: 'flex-end' }, children: [
|
|
183
|
+
h('button', { style: styles.btn, onClick: props.onClose, children: '取消' }),
|
|
184
|
+
h('button', { style: styles.btnPrimary, onClick: submit, children: '添加' }),
|
|
185
|
+
] }),
|
|
186
|
+
] }),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 把 ServerRow 数组转为 JSON 字符串(给文本域预填) */
|
|
191
|
+
function rowsToJsonText(rows: ServerRow[]): string {
|
|
192
|
+
// 用对象映射形式(Claude Desktop 友好),key 就是 serverName
|
|
193
|
+
const map: Record<string, Record<string, unknown>> = {}
|
|
194
|
+
for (const r of rows) {
|
|
195
|
+
const { serverName, ...cfg } = r
|
|
196
|
+
map[serverName] = cfg
|
|
197
|
+
}
|
|
198
|
+
return JSON.stringify(map, null, 2)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 把任意 JSON 文本解析为 ServerRow 数组(宽容:支持四种形态) */
|
|
202
|
+
function parseJsonText(text: string): ServerRow[] {
|
|
203
|
+
const parsed = JSON.parse(text) as unknown
|
|
204
|
+
// mcpServers 包裹 → 解包
|
|
205
|
+
const root = (parsed && typeof parsed === 'object' && 'mcpServers' in (parsed as Record<string, unknown>)
|
|
206
|
+
? (parsed as Record<string, unknown>).mcpServers : parsed) as unknown
|
|
207
|
+
// 对象映射
|
|
208
|
+
if (root && typeof root === 'object' && !Array.isArray(root) && !('servers' in (root as Record<string, unknown>))) {
|
|
209
|
+
return Object.entries(root as Record<string, Record<string, unknown>>).map(([serverName, cfg]) => ({
|
|
210
|
+
serverName,
|
|
211
|
+
command: typeof cfg.command === 'string' ? cfg.command : '',
|
|
212
|
+
args: Array.isArray(cfg.args) ? cfg.args : [],
|
|
213
|
+
env: (cfg.env && typeof cfg.env === 'object') ? cfg.env as Record<string, string> : {},
|
|
214
|
+
cwd: typeof cfg.cwd === 'string' ? cfg.cwd : '',
|
|
215
|
+
enabled: cfg.enabled !== false, // 缺省 true
|
|
216
|
+
}))
|
|
217
|
+
}
|
|
218
|
+
// 数组
|
|
219
|
+
if (Array.isArray(parsed)) {
|
|
220
|
+
return parsed.map(raw => {
|
|
221
|
+
const cfg = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {}
|
|
222
|
+
return {
|
|
223
|
+
serverName: typeof cfg.serverName === 'string' ? cfg.serverName : '',
|
|
224
|
+
command: typeof cfg.command === 'string' ? cfg.command : '',
|
|
225
|
+
args: Array.isArray(cfg.args) ? cfg.args : [],
|
|
226
|
+
env: (cfg.env && typeof cfg.env === 'object') ? cfg.env as Record<string, string> : {},
|
|
227
|
+
cwd: typeof cfg.cwd === 'string' ? cfg.cwd : '',
|
|
228
|
+
enabled: cfg.enabled !== false,
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
// { servers: [...] }
|
|
233
|
+
if (root && typeof root === 'object' && 'servers' in (root as Record<string, unknown>)) {
|
|
234
|
+
return parseJsonText(JSON.stringify((root as { servers: unknown[] }).servers))
|
|
235
|
+
}
|
|
236
|
+
throw new Error('JSON 顶层必须是对象映射或数组')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 主组件 */
|
|
240
|
+
function McpServersSection() {
|
|
241
|
+
// 服务器列表(null = 加载中)
|
|
242
|
+
const [rows, setRows] = React.useState<ServerRow[] | null>(null)
|
|
243
|
+
// 视图模式
|
|
244
|
+
const [mode, setMode] = React.useState<'list' | 'json'>('list')
|
|
245
|
+
// JSON 文本域(切到 json 模式时预填)
|
|
246
|
+
const [jsonText, setJsonText] = React.useState('')
|
|
247
|
+
// 添加服务器弹窗(暂留,目前列表模式不显示入口)
|
|
248
|
+
const [adding, setAdding] = React.useState(false)
|
|
249
|
+
const [busy, setBusy] = React.useState(false)
|
|
250
|
+
// 未保存修改标记:只驱动保存按钮的强调态
|
|
251
|
+
const [dirty, setDirty] = React.useState(false)
|
|
252
|
+
|
|
253
|
+
// 首次加载
|
|
254
|
+
React.useEffect(() => {
|
|
255
|
+
refresh()
|
|
256
|
+
.catch(() => { setRows([]); setJsonText('{}'); alert('加载失败:无法连接 host 端点') })
|
|
257
|
+
}, [])
|
|
258
|
+
|
|
259
|
+
/** 从 host 拉取服务器列表(含状态)并更新 UI */
|
|
260
|
+
const refresh = () => fetch('/dsh-mcp-plus/servers')
|
|
261
|
+
.then(r => r.json())
|
|
262
|
+
.then((d: { servers?: unknown[] }) => {
|
|
263
|
+
const reloaded = (d.servers ?? []).map(raw => {
|
|
264
|
+
const s = raw as Record<string, unknown>
|
|
265
|
+
const status = (s.status === 'connecting' || s.status === 'connected' || s.status === 'disconnected' || s.status === 'disabled')
|
|
266
|
+
? s.status : 'connecting'
|
|
267
|
+
return {
|
|
268
|
+
serverName: typeof s.serverName === 'string' ? s.serverName : '',
|
|
269
|
+
command: typeof s.command === 'string' ? s.command : '',
|
|
270
|
+
args: Array.isArray(s.args) ? s.args : [],
|
|
271
|
+
env: (s.env && typeof s.env === 'object') ? s.env as Record<string, string> : {},
|
|
272
|
+
cwd: typeof s.cwd === 'string' ? s.cwd : '',
|
|
273
|
+
enabled: s.enabled !== false,
|
|
274
|
+
status,
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
setRows(reloaded)
|
|
278
|
+
setJsonText(rowsToJsonText(reloaded))
|
|
279
|
+
return reloaded
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
/** 保存:只写盘,立即完成;重连由 host 异步做,状态经轮询圆点展示 */
|
|
283
|
+
const save = (payload: ServerRow[]) => {
|
|
284
|
+
setBusy(true)
|
|
285
|
+
// 写入前剥离 status 运行时字段(只保留配置字段)
|
|
286
|
+
const clean = payload.map(({ serverName, command, args, env, cwd, enabled }) => ({
|
|
287
|
+
transport: 'stdio' as const,
|
|
288
|
+
serverName, command, args, env, cwd,
|
|
289
|
+
...(enabled !== undefined ? { enabled } : {}),
|
|
290
|
+
}))
|
|
291
|
+
fetch('/dsh-mcp-plus/servers', {
|
|
292
|
+
method: 'PUT',
|
|
293
|
+
headers: { 'Content-Type': 'application/json' },
|
|
294
|
+
body: JSON.stringify({ servers: clean }),
|
|
295
|
+
})
|
|
296
|
+
.then(r => r.json())
|
|
297
|
+
.then((data: { ok?: boolean; error?: string; count?: number }) => {
|
|
298
|
+
if (data.ok) {
|
|
299
|
+
// 立即刷新一次(此刻启用台应已标 connecting,圆点变黄)
|
|
300
|
+
void refresh()
|
|
301
|
+
// 轮询:最多 90 次(约 90 秒,npx 冷启动可能很慢),直到没有 connecting 状态
|
|
302
|
+
let polls = 0
|
|
303
|
+
const timer = setInterval(() => {
|
|
304
|
+
polls += 1
|
|
305
|
+
void refresh().then(list => {
|
|
306
|
+
if (!list.some(r => r.status === 'connecting') || polls >= 90) clearInterval(timer)
|
|
307
|
+
}).catch(() => clearInterval(timer))
|
|
308
|
+
}, 1000)
|
|
309
|
+
} else {
|
|
310
|
+
alert('保存失败:' + (data.error ?? '未知错误'))
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
.catch(err => { alert('保存失败:' + String(err)) })
|
|
314
|
+
.finally(() => setBusy(false))
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (rows === null) {
|
|
318
|
+
return h('div', { style: { ...styles.section, color: 'var(--muted-foreground, #888)' }, children: '加载中…' })
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---- 顶部条:模式切换 ----
|
|
322
|
+
const toggleButton = mode === 'list'
|
|
323
|
+
? h('button', { style: styles.btn, onClick: () => { setMode('json'); setJsonText(rowsToJsonText(rows)) }, children: '修改配置(JSON)' })
|
|
324
|
+
: h('button', { style: styles.btn, onClick: () => { setMode('list') }, children: '关闭编辑' })
|
|
325
|
+
const topbar = h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }, children: [
|
|
326
|
+
h('div', { children: [
|
|
327
|
+
h('h2', { style: { margin: '0 0 4px' }, children: 'MCP 服务器' }),
|
|
328
|
+
h('p', { style: styles.desc, children: `共 ${rows.length} 台服务器,${rows.filter(r => r.enabled).length} 台启用中。` }),
|
|
329
|
+
] }),
|
|
330
|
+
toggleButton,
|
|
331
|
+
]})
|
|
332
|
+
|
|
333
|
+
// ---- 列表视图 ----
|
|
334
|
+
const listView = h('div', { children: [
|
|
335
|
+
rows.length === 0
|
|
336
|
+
? h('p', { style: styles.desc, children: '暂无服务器。点击右上角「修改配置(JSON)」添加。' })
|
|
337
|
+
: h('div', {
|
|
338
|
+
style: { marginBottom: '16px' },
|
|
339
|
+
children: rows.map((row, i) => {
|
|
340
|
+
const cmd = [row.command, ...row.args].filter(Boolean).join(' ')
|
|
341
|
+
// 禁用行:名字 + 命令都用更弱的灰色 token(替代 opacity 叠加,暗色下更明显)
|
|
342
|
+
const dimName = row.enabled ? styles.rowCellName : { ...styles.rowCellName, color: 'var(--dsw-alias-label-tertiary)', fontWeight: 400 as const }
|
|
343
|
+
const dimCmd = row.enabled ? styles.rowCellCmd : { ...styles.rowCellCmd, color: 'var(--dsw-alias-label-tertiary)' }
|
|
344
|
+
// 状态圆点三态:connected=绿 / connecting=黄(闪烁动画) / 其他=灰
|
|
345
|
+
const dotColor = row.status === 'connected'
|
|
346
|
+
? 'var(--dsw-alias-state-success-primary)'
|
|
347
|
+
: row.status === 'connecting'
|
|
348
|
+
? 'var(--dsw-alias-state-warn-primary)'
|
|
349
|
+
: 'var(--dsw-alias-label-tertiary)'
|
|
350
|
+
const statusLabel = row.status === 'connected' ? '已连接'
|
|
351
|
+
: row.status === 'connecting' ? '连接中…'
|
|
352
|
+
: row.status === 'disabled' ? '已禁用' : '未连接'
|
|
353
|
+
return h('div', { key: row.serverName || String(i), style: styles.row, children: [
|
|
354
|
+
h(Checkbox, { checked: row.enabled, onChange: v => { setRows(rows.map((r, j) => j === i ? { ...r, enabled: v } : r)); setDirty(true) } }),
|
|
355
|
+
h('div', { style: { ...dimName, display: 'flex', alignItems: 'center', gap: '8px' }, children: [
|
|
356
|
+
h('span', {
|
|
357
|
+
title: statusLabel,
|
|
358
|
+
'data-status': row.status,
|
|
359
|
+
style: {
|
|
360
|
+
width: '10px', height: '10px',
|
|
361
|
+
borderRadius: '50%',
|
|
362
|
+
background: dotColor,
|
|
363
|
+
display: 'inline-block',
|
|
364
|
+
flex: '0 0 auto',
|
|
365
|
+
boxShadow: row.status === 'connected' ? `0 0 4px ${dotColor}` : 'none',
|
|
366
|
+
// 连接中:1 秒周期的呼吸闪烁(CSS 动画注入见下方 style 标签)
|
|
367
|
+
animation: row.status === 'connecting' ? 'dshMcpPlusPulse 1s ease-in-out infinite' : undefined,
|
|
368
|
+
},
|
|
369
|
+
}),
|
|
370
|
+
row.serverName || '(未命名)',
|
|
371
|
+
] }),
|
|
372
|
+
h('div', { style: dimCmd, title: cmd, children: cmd }),
|
|
373
|
+
] })
|
|
374
|
+
}),
|
|
375
|
+
}),
|
|
376
|
+
h('p', { style: styles.desc, children: '通过「修改配置(JSON)」增删服务器。' }),
|
|
377
|
+
] })
|
|
378
|
+
|
|
379
|
+
// ---- JSON 编辑视图 ----
|
|
380
|
+
const jsonView = h('div', { children: [
|
|
381
|
+
h('p', { style: styles.desc, children: '直接编辑 JSON(支持对象映射 { 名字: { command, args, env, cwd, enabled } } 或数组)。' }),
|
|
382
|
+
h('textarea', { style: styles.textarea, value: jsonText, spellCheck: false,
|
|
383
|
+
// Ctrl+Enter 快捷保存(JSON 编辑的通用习惯)
|
|
384
|
+
onKeyDown: (e: { key: string; ctrlKey: boolean; preventDefault: () => void }) => {
|
|
385
|
+
if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); saveDirtyJson() }
|
|
386
|
+
},
|
|
387
|
+
onChange: (e: { target: { value: string } }) => { setJsonText(e.target.value); setDirty(true) } }),
|
|
388
|
+
] })
|
|
389
|
+
|
|
390
|
+
// ---- 底部操作台:统计摘要 + 取消/保存 ----
|
|
391
|
+
// dirty 只驱动保存按钮的强调态(不再显示文字提示)
|
|
392
|
+
const enabledCount = rows.filter(r => r.enabled).length
|
|
393
|
+
const disabledCount = rows.length - enabledCount
|
|
394
|
+
|
|
395
|
+
/** JSON 模式保存:先解析预检,成功才发请求(Ctrl+Enter 和按钮共用) */
|
|
396
|
+
const saveDirtyJson = () => {
|
|
397
|
+
try {
|
|
398
|
+
save(parseJsonText(jsonText))
|
|
399
|
+
} catch (err) {
|
|
400
|
+
alert('JSON 解析失败:' + (err instanceof Error ? err.message : String(err)))
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const sticky = h('div', { style: styles.stickyBar, children: [
|
|
405
|
+
// 左侧:统计摘要 + 行为提示
|
|
406
|
+
h('div', { style: styles.stickySummary, children: [
|
|
407
|
+
h('div', { style: styles.stickyCount, children: `${enabledCount} 台启用中${disabledCount > 0 ? `,${disabledCount} 台已禁用` : ''}` }),
|
|
408
|
+
h('div', { style: styles.stickyHint, children: dirty ? '有未保存的修改' : '保存后将自动重连' }),
|
|
409
|
+
] }),
|
|
410
|
+
// 右侧:取消 + 保存
|
|
411
|
+
h('div', { style: { display: 'flex', gap: '8px' }, children: [
|
|
412
|
+
h('button', {
|
|
413
|
+
style: styles.btn,
|
|
414
|
+
onClick: () => {
|
|
415
|
+
// 取消:丢弃本地修改,重新从 host 拉取(两种模式都可用)
|
|
416
|
+
fetch('/dsh-mcp-plus/servers').then(r => r.json()).then((d: { servers?: unknown[] }) => {
|
|
417
|
+
const reloaded = (d.servers ?? []).map(raw => {
|
|
418
|
+
const s = raw as Record<string, unknown>
|
|
419
|
+
const status = (s.status === 'connected' || s.status === 'disconnected' || s.status === 'disabled')
|
|
420
|
+
? s.status : 'disconnected'
|
|
421
|
+
return {
|
|
422
|
+
serverName: typeof s.serverName === 'string' ? s.serverName : '',
|
|
423
|
+
command: typeof s.command === 'string' ? s.command : '',
|
|
424
|
+
args: Array.isArray(s.args) ? s.args : [],
|
|
425
|
+
env: (s.env && typeof s.env === 'object') ? s.env as Record<string, string> : {},
|
|
426
|
+
cwd: typeof s.cwd === 'string' ? s.cwd : '',
|
|
427
|
+
enabled: s.enabled !== false,
|
|
428
|
+
status,
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
setRows(reloaded)
|
|
432
|
+
setJsonText(rowsToJsonText(reloaded))
|
|
433
|
+
setMode('list')
|
|
434
|
+
setDirty(false)
|
|
435
|
+
}).catch(() => alert('取消失败:无法从 host 重新加载'))
|
|
436
|
+
},
|
|
437
|
+
children: '取消',
|
|
438
|
+
}),
|
|
439
|
+
h('button', {
|
|
440
|
+
style: { ...styles.btnPrimary, opacity: busy ? 0.5 : 1, outline: dirty ? '2px solid var(--dsw-alias-state-business-primary)' : 'none', outlineOffset: '2px' },
|
|
441
|
+
disabled: busy,
|
|
442
|
+
onClick: () => {
|
|
443
|
+
if (mode === 'list') { save(rows); return }
|
|
444
|
+
saveDirtyJson()
|
|
445
|
+
},
|
|
446
|
+
children: busy ? '保存中…' : '保存',
|
|
447
|
+
}),
|
|
448
|
+
] }),
|
|
449
|
+
] })
|
|
450
|
+
|
|
451
|
+
return h('div', { style: styles.section, children: [
|
|
452
|
+
topbar,
|
|
453
|
+
mode === 'list' ? listView : jsonView,
|
|
454
|
+
sticky,
|
|
455
|
+
] })
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function apply(ctx: unknown): void {
|
|
459
|
+
const c = ctx as any
|
|
460
|
+
// 注入连接中状态的闪烁动画(一次性,幂等)
|
|
461
|
+
if (typeof document !== 'undefined' && !document.getElementById('dsh-mcp-plus-styles')) {
|
|
462
|
+
const style = document.createElement('style')
|
|
463
|
+
style.id = 'dsh-mcp-plus-styles'
|
|
464
|
+
style.textContent = '@keyframes dshMcpPlusPulse { 0%,100% { opacity: 1 } 50% { opacity: 0.35 } }'
|
|
465
|
+
document.head.appendChild(style)
|
|
466
|
+
}
|
|
467
|
+
c.slots.inject('settings.section', function* () {
|
|
468
|
+
yield c.slots.register(
|
|
469
|
+
{ name: 'settings.section', id: 'mcp-servers', order: 40, label: () => 'MCP 服务器', inject: () => ({}) },
|
|
470
|
+
McpServersSection,
|
|
471
|
+
)
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return { name, inject, apply }
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
window.__ModuleLoader__.load({
|
|
480
|
+
id: 'dsh-mcp-plus',
|
|
481
|
+
factory: makeFactory(),
|
|
482
|
+
})
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// config.ts —— 服务器列表的「形状」定义
|
|
2
|
+
// 这里只回答一个问题:一台 MCP 服务器、一个服务器列表,长什么样?
|
|
3
|
+
// 类型(interface)给 TypeScript 用,schema 给运行时校验用。
|
|
4
|
+
import z from '@deepseek-ai/schemastery'
|
|
5
|
+
|
|
6
|
+
/** 一台 stdio MCP 服务器的配置(字段名与官方 dsh-mcp-client 保持一致) */
|
|
7
|
+
export interface ServerConfig {
|
|
8
|
+
transport: 'stdio'
|
|
9
|
+
/** 这台服务器的名字:用作工具名命名空间(mcp__<serverName>__<tool>) */
|
|
10
|
+
serverName: string
|
|
11
|
+
/** 启动服务器的可执行文件 */
|
|
12
|
+
command: string
|
|
13
|
+
/** 传给可执行文件的参数 */
|
|
14
|
+
args: string[]
|
|
15
|
+
/** 额外的环境变量 */
|
|
16
|
+
env: Record<string, string>
|
|
17
|
+
/** 子进程工作目录 */
|
|
18
|
+
cwd: string
|
|
19
|
+
/** 是否启用(可选,默认 true)。禁用时 host 半跳过连接 */
|
|
20
|
+
enabled?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 服务器列表:配置文件(servers.json)的顶层形状 */
|
|
24
|
+
export interface Config {
|
|
25
|
+
servers: ServerConfig[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 运行时校验器:required() 必填、default() 可选带默认值 */
|
|
29
|
+
export const ServerSchema = z.object({
|
|
30
|
+
transport: z.const('stdio'),
|
|
31
|
+
serverName: z.string().required().pattern(/^[A-Za-z0-9_-]{1,32}$/),
|
|
32
|
+
command: z.string().required(),
|
|
33
|
+
args: z.array(String).default([]),
|
|
34
|
+
env: z.dict(String).default({}),
|
|
35
|
+
cwd: z.string().default(''),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** 服务器列表 schema:同时是 cordis 的 Config(如果将来 cordis.yml 传 config 也用得上) */
|
|
39
|
+
export const Config = z.object({
|
|
40
|
+
servers: z.array(ServerSchema).default([]),
|
|
41
|
+
}) as unknown as z<Config>
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 把用户输入规范化为 { servers: [...] }:
|
|
45
|
+
* 支持四种顶层形态(统一转成 schema 认识的形状)——
|
|
46
|
+
* ① { servers: [...] } —— 原生格式(servers.json 的存储格式)
|
|
47
|
+
* ② [ {...}, ... ] —— 裸数组
|
|
48
|
+
* ③ { "名字": {command,...}} —— 对象映射,key 即 serverName
|
|
49
|
+
* ④ { mcpServers: { 形态③ } } —— Claude Desktop 完整格式(mcpServers 包裹)
|
|
50
|
+
*
|
|
51
|
+
* 另外对每个条目做宽容处理:
|
|
52
|
+
* - 缺 transport 字段 → 自动补 'stdio'
|
|
53
|
+
* - 忽略未知字段(enabled 等)
|
|
54
|
+
*/
|
|
55
|
+
function normalizeInput(input: unknown): unknown {
|
|
56
|
+
// 形态④:mcpServers 包裹 → 解包后按形态③处理
|
|
57
|
+
const root = input as Record<string, unknown> | null
|
|
58
|
+
if (typeof root === 'object' && root !== null && !Array.isArray(root)
|
|
59
|
+
&& typeof root.mcpServers === 'object' && root.mcpServers !== null) {
|
|
60
|
+
return normalizeInput(root.mcpServers)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 形态③:对象映射(无 servers / mcpServers key)
|
|
64
|
+
if (typeof root === 'object' && root !== null && !Array.isArray(root)
|
|
65
|
+
&& !('servers' in root)) {
|
|
66
|
+
return {
|
|
67
|
+
servers: Object.entries(root).map(([serverName, raw]) => {
|
|
68
|
+
const cfg = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
|
|
69
|
+
return {
|
|
70
|
+
// 缺 transport 补 stdio;已有则保留(宽容未知形态)
|
|
71
|
+
transport: (typeof cfg.transport === 'string' ? cfg.transport : 'stdio'),
|
|
72
|
+
serverName,
|
|
73
|
+
command: cfg.command ?? '',
|
|
74
|
+
args: Array.isArray(cfg.args) ? cfg.args : [],
|
|
75
|
+
env: (typeof cfg.env === 'object' && cfg.env !== null) ? cfg.env : {},
|
|
76
|
+
cwd: typeof cfg.cwd === 'string' ? cfg.cwd : '',
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// 形态②:裸数组(条目内同样补 transport)
|
|
82
|
+
if (Array.isArray(input)) {
|
|
83
|
+
return {
|
|
84
|
+
servers: input.map((raw) => {
|
|
85
|
+
const cfg = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
|
|
86
|
+
return { transport: (typeof cfg.transport === 'string' ? cfg.transport : 'stdio'), ...cfg }
|
|
87
|
+
}),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// 形态①:原样
|
|
91
|
+
return input
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 校验一段未知数据(比如从 servers.json 读出来的 JSON)是不是合法的服务器列表。
|
|
96
|
+
* schemastery 是 standard-schema 实现,校验入口是 ['~standard'].validate(input),
|
|
97
|
+
* 返回 { value }(成功)或 { issues }(失败),不会静默填默认值。
|
|
98
|
+
* @returns 校验通过后的服务器列表
|
|
99
|
+
* @throws 校验失败时抛错(带 issues 详情)
|
|
100
|
+
*/
|
|
101
|
+
export function validateServers(input: unknown): ServerConfig[] {
|
|
102
|
+
const std = (Config as unknown as { ['~standard']: { validate(x: unknown): { value?: unknown; issues?: unknown } } })['~standard']
|
|
103
|
+
const result = std.validate(normalizeInput(input))
|
|
104
|
+
if (result.issues) {
|
|
105
|
+
throw new Error('服务器配置校验失败: ' + JSON.stringify(result.issues))
|
|
106
|
+
}
|
|
107
|
+
return (result.value as Config).servers
|
|
108
|
+
}
|