@sunt2049/dsh-mcp-manager 0.2.0 → 0.3.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/client.js CHANGED
@@ -80,6 +80,21 @@ window.__ModuleLoader__.load({
80
80
  return env
81
81
  }
82
82
 
83
+ /** `Name: Value` per line; the first colon separates, so values may contain colons. */
84
+ function parseHeaders(text) {
85
+ const headers = {}
86
+ for (const raw of String(text || '').split('\n')) {
87
+ const line = raw.trim()
88
+ if (!line || line.startsWith('#')) continue
89
+ const cut = line.indexOf(':')
90
+ if (cut <= 0) continue
91
+ const name = line.slice(0, cut).trim()
92
+ const value = line.slice(cut + 1).trim()
93
+ if (name && value) headers[name] = value
94
+ }
95
+ return headers
96
+ }
97
+
83
98
  function verdictLabel(verdict) {
84
99
  switch (verdict) {
85
100
  case 'port-closed':
@@ -275,6 +290,8 @@ window.__ModuleLoader__.load({
275
290
  return h('div', { style: S.field }, h('span', { style: S.label }, label), control)
276
291
  }
277
292
 
293
+ const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/
294
+
278
295
  function EditForm(props) {
279
296
  const server = props.server
280
297
  const isHttp = server.transport === 'streamable-http'
@@ -285,6 +302,8 @@ window.__ModuleLoader__.load({
285
302
  server.toolCallTimeoutMs === null ? '' : String(server.toolCallTimeoutMs),
286
303
  )
287
304
  const [failFast, setFailFast] = useState(server.failOnStartupError === true)
305
+ const [headerRows, setHeaderRows] = useState([{ name: '', value: '' }])
306
+ const [dropped, setDropped] = useState([])
288
307
  const [busy, setBusy] = useState(false)
289
308
  const [failure, setFailure] = useState(null)
290
309
 
@@ -304,6 +323,19 @@ window.__ModuleLoader__.load({
304
323
  return
305
324
  }
306
325
  patch.url = url.trim()
326
+
327
+ const headers = {}
328
+ for (const name of dropped) headers[name] = ''
329
+ for (const row of headerRows) {
330
+ const name = row.name.trim()
331
+ if (!name) continue
332
+ if (!HEADER_NAME.test(name)) {
333
+ setFailure(`请求头名不合法:${name}`)
334
+ return
335
+ }
336
+ headers[name] = row.value
337
+ }
338
+ if (Object.keys(headers).length > 0) patch.headers = headers
307
339
  } else {
308
340
  if (!command.trim()) {
309
341
  setFailure('command 不能为空')
@@ -324,7 +356,7 @@ window.__ModuleLoader__.load({
324
356
  return
325
357
  }
326
358
  props.onSaved()
327
- }, [argsText, command, failFast, isHttp, props, timeout, url])
359
+ }, [argsText, command, dropped, failFast, headerRows, isHttp, props, timeout, url])
328
360
 
329
361
  return h(
330
362
  'div',
@@ -356,6 +388,88 @@ window.__ModuleLoader__.load({
356
388
  }),
357
389
  ),
358
390
  ),
391
+ isHttp
392
+ ? field(
393
+ '请求头(可选,同名覆盖;清空值不影响已有值)',
394
+ h(
395
+ 'div',
396
+ null,
397
+ (server.headerKeys || []).length > 0
398
+ ? h(
399
+ 'div',
400
+ { style: { ...S.row, gap: '5px', flexWrap: 'wrap', marginBottom: '6px' } },
401
+ (server.headerKeys || []).map((name) =>
402
+ dropped.includes(name)
403
+ ? h(
404
+ 'button',
405
+ {
406
+ key: name,
407
+ style: { ...S.btn, padding: '1px 7px', fontSize: '11.5px', textDecoration: 'line-through', opacity: 0.55 },
408
+ title: '撤销删除',
409
+ onClick: () => setDropped((prev) => prev.filter((item) => item !== name)),
410
+ },
411
+ name,
412
+ )
413
+ : h(
414
+ 'button',
415
+ {
416
+ key: name,
417
+ style: { ...S.btn, padding: '1px 7px', fontSize: '11.5px' },
418
+ title: '点击标记删除',
419
+ onClick: () => setDropped((prev) => [...prev, name]),
420
+ },
421
+ name,
422
+ h('span', { style: { color: C.dim, marginLeft: '4px' } }, '×'),
423
+ ),
424
+ ),
425
+ )
426
+ : null,
427
+ headerRows.map((row, index) =>
428
+ h(
429
+ 'div',
430
+ { key: 'h' + index, style: { ...S.row, gap: '5px', marginBottom: '4px' } },
431
+ h('input', {
432
+ style: { ...S.input, flex: '0 0 38%' },
433
+ placeholder: 'Header 名',
434
+ value: row.name,
435
+ spellCheck: false,
436
+ onChange: (event) =>
437
+ setHeaderRows((prev) =>
438
+ prev.map((item, at) => (at === index ? { ...item, name: event.target.value } : item)),
439
+ ),
440
+ }),
441
+ h('input', {
442
+ style: { ...S.input, flex: '1 1 auto' },
443
+ placeholder: '值',
444
+ value: row.value,
445
+ spellCheck: false,
446
+ onChange: (event) =>
447
+ setHeaderRows((prev) =>
448
+ prev.map((item, at) => (at === index ? { ...item, value: event.target.value } : item)),
449
+ ),
450
+ }),
451
+ h(
452
+ 'button',
453
+ {
454
+ style: S.btn,
455
+ onClick: () => setHeaderRows((prev) => prev.filter((_, at) => at !== index)),
456
+ title: '移除此行',
457
+ },
458
+ '×',
459
+ ),
460
+ ),
461
+ ),
462
+ h(
463
+ 'button',
464
+ {
465
+ style: { ...S.btn, marginTop: '2px' },
466
+ onClick: () => setHeaderRows((prev) => [...prev, { name: '', value: '' }]),
467
+ },
468
+ '+ 添加请求头',
469
+ ),
470
+ ),
471
+ )
472
+ : null,
359
473
  field(
360
474
  'toolCallTimeoutMs(留空表示用默认 60000)',
361
475
  h('input', {
@@ -372,8 +486,8 @@ window.__ModuleLoader__.load({
372
486
  h('input', { type: 'checkbox', checked: failFast, onChange: (e) => setFailFast(e.target.checked) }),
373
487
  '连接失败时拒绝激活(failOnStartupError)',
374
488
  ),
375
- server.envKeys.length || server.headerKeys.length
376
- ? h(Notice, null, 'env / headers 的值不在页面回显,以免泄露密钥;这两项请直接改 patch。')
489
+ server.envKeys.length
490
+ ? h(Notice, null, 'env 的值不在页面回显,以免泄露密钥;要改 env 请直接改 patch。')
377
491
  : null,
378
492
  failure ? h(Notice, { error: true }, failure) : null,
379
493
  h(
@@ -396,14 +510,18 @@ window.__ModuleLoader__.load({
396
510
  const [command, setCommand] = useState('')
397
511
  const [argsText, setArgsText] = useState('')
398
512
  const [envText, setEnvText] = useState('')
513
+ const [headersText, setHeadersText] = useState('')
399
514
  const [timeout, setTimeoutMs] = useState('')
400
515
  const [busy, setBusy] = useState(false)
401
516
  const [failure, setFailure] = useState(null)
402
517
 
403
518
  const submit = useCallback(async () => {
404
519
  const server = { serverName: serverName.trim(), transport }
405
- if (transport === 'streamable-http') server.url = url.trim()
406
- else {
520
+ if (transport === 'streamable-http') {
521
+ server.url = url.trim()
522
+ const headers = parseHeaders(headersText)
523
+ if (Object.keys(headers).length > 0) server.headers = headers
524
+ } else {
407
525
  server.command = command.trim()
408
526
  server.args = argsText.split('\n').map((line) => line.trim()).filter(Boolean)
409
527
  const env = parseEnv(envText)
@@ -419,7 +537,7 @@ window.__ModuleLoader__.load({
419
537
  return
420
538
  }
421
539
  props.onDone()
422
- }, [argsText, command, envText, props, serverName, timeout, transport, url])
540
+ }, [argsText, command, envText, headersText, props, serverName, timeout, transport, url])
423
541
 
424
542
  const isHttp = transport === 'streamable-http'
425
543
  return h(
@@ -447,7 +565,21 @@ window.__ModuleLoader__.load({
447
565
  ),
448
566
  ),
449
567
  isHttp
450
- ? field('url', h('input', { style: S.input, value: url, onChange: (e) => setUrl(e.target.value), spellCheck: false }))
568
+ ? h(
569
+ React.Fragment,
570
+ null,
571
+ field('url', h('input', { style: S.input, value: url, onChange: (e) => setUrl(e.target.value), spellCheck: false })),
572
+ field(
573
+ 'headers(每行 Name: Value,可留空)',
574
+ h('textarea', {
575
+ style: { ...S.input, minHeight: '58px', resize: 'vertical' },
576
+ value: headersText,
577
+ onChange: (e) => setHeadersText(e.target.value),
578
+ spellCheck: false,
579
+ placeholder: 'Authorization: Bearer xxx',
580
+ }),
581
+ ),
582
+ )
451
583
  : h(
452
584
  React.Fragment,
453
585
  null,
package/index.js CHANGED
@@ -35,6 +35,9 @@ import {
35
35
  const MCP_PLUGIN = '@deepseek-ai/dsh-mcp-client'
36
36
  const SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/
37
37
 
38
+ /** RFC 7230 token, the only thing a header name may contain. */
39
+ const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/
40
+
38
41
  /** Config keys this console is willing to write back through configEditor. */
39
42
  const EDITABLE = new Set([
40
43
  'serverName',
@@ -376,6 +379,18 @@ function readNewServer(input) {
376
379
  return out
377
380
  }
378
381
  next.url = url
382
+ const headers = asObject(config.headers)
383
+ const names = Object.keys(headers)
384
+ if (names.length > 0) {
385
+ next.headers = {}
386
+ for (const name of names) {
387
+ if (!HEADER_NAME.test(name)) {
388
+ out.error = `请求头名不合法:${name}`
389
+ return out
390
+ }
391
+ if (typeof headers[name] === 'string') next.headers[name] = headers[name]
392
+ }
393
+ }
379
394
  } else {
380
395
  const command = text(config.command).trim()
381
396
  if (!command) {
@@ -692,7 +707,21 @@ export function apply(ctx) {
692
707
  }
693
708
  await hostCtx.configEditor.edit(entry, (current) => {
694
709
  const next = { ...asObject(current) }
695
- for (const key of Object.keys(clean)) next[key] = clean[key]
710
+ for (const key of Object.keys(clean)) {
711
+ if (key === 'headers') continue
712
+ next[key] = clean[key]
713
+ }
714
+ // The panel never reads header values back, so a header patch is a
715
+ // set of writes and deletes merged over what the row already has.
716
+ // An empty string deletes that header.
717
+ if (clean.headers) {
718
+ const merged = { ...asObject(next.headers) }
719
+ for (const [name, value] of Object.entries(clean.headers)) {
720
+ if (value === '') delete merged[name]
721
+ else merged[name] = value
722
+ }
723
+ next.headers = merged
724
+ }
696
725
  return next
697
726
  })
698
727
  sendJson(response, 200, { ok: true, id, applied: Object.keys(clean), live: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunt2049/dsh-mcp-manager",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "MCP 控制台:一个 profile 里所有 MCP 服务器的总览、实时工具清单与完整 JSON Schema、连通性探测,以及增删/停用与配置编辑。",
6
6
  "keywords": [
package/patch-doc.js CHANGED
@@ -241,6 +241,12 @@ export function buildRowLines(id, config) {
241
241
 
242
242
  if (config.transport === 'streamable-http') {
243
243
  field('url', quote(config.url))
244
+ const headers = config.headers && typeof config.headers === 'object' ? config.headers : {}
245
+ const names = Object.keys(headers)
246
+ if (names.length > 0) {
247
+ lines.push(' headers:')
248
+ for (const name of names) lines.push(` ${name}: ${quote(headers[name])}`)
249
+ }
244
250
  } else {
245
251
  field('command', quote(config.command))
246
252
  const args = Array.isArray(config.args) ? config.args : []