dsh-mcp-skills 0.2.3 → 0.5.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-mcp-skills
2
2
 
3
- 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件,在网页设置面板中新增两个管理页面:
3
+ 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件,在网页设置面板中新增一个 **「能力」** 页面,内含 **MCP** / **Skills** 两个标签:
4
4
 
5
5
  - **Skills** — 浏览、导入、删除、开关用户自有技能(存放于 `$DSH_HOME/skills`)。
6
6
  - **MCP** — 管理 MCP 服务器配置(stdio / Streamable HTTP),支持增删改与连接测试。启用的服务器写入当前 profile 的 `cordis.patch.yml`,重启后自动连接,工具注册为 `mcp__<server>__<tool>`。
@@ -9,11 +9,11 @@
9
9
 
10
10
  **Skills 页面**
11
11
 
12
- ![Skills 页面](images/skills.png)
12
+ <img src="images/skills.png" alt="Skills 页面" style="zoom:33%;" />
13
13
 
14
14
  **MCP 页面**
15
15
 
16
- ![MCP 页面](images/mcp.png)
16
+ <img src="images/mcp.png" alt="MCP 页面" style="zoom:33%;" />
17
17
 
18
18
  ## 安装
19
19
 
@@ -46,7 +46,7 @@ dsh --profile web
46
46
 
47
47
  ## 行为说明
48
48
 
49
- - MCP 服务器保存在 `$DSH_HOME/mcp-servers.yaml`;启用的服务器会被写入当前 profile 的 `cordis.patch.yml`,重启 dsh 后自动连接。插件只增删自己生成的 `mcp-*` 行,不改动该文件的其他内容:注释、锚点与 `!!js` 表达式都会原样保留,写入是原子的,并会保留一份 `.bak` 备份。
49
+ - MCP 服务器**直接保存**在当前 profile 的 `cordis.patch.yml`(官方推荐位置),重启 dsh 后自动连接;没有额外的清单文件。插件只增删 `mcp-*` 行(以及被接管的外部行),不改动该文件的其他内容:注释、锚点与 `!!js` 表达式都会原样保留,写入是原子的,并会保留一份 `.bak` 备份。
50
50
  - 若 profile 的 `cordis.patch.yml` 存在语法错误,插件会拒绝改写并报错,而不是覆盖它。
51
51
  - 连接测试 15 秒超时,超时会关闭连接并回收子进程。测试与真实挂载使用同一套环境清洗规则:`*_KEY` / `*_PASSWORD` / `*_SECRET` / `*_TOKEN` 与 `DSH_*` 变量不会传给 MCP 子进程,需要凭据时请在服务器的环境变量里显式配置。
52
52
  - 技能存放在 `$DSH_HOME/skills`。zip 导入支持单个技能(根目录 `SKILL.md` 或 `<name>/SKILL.md`)或多个技能目录,`template/` 会被跳过,已存在的技能不会被覆盖(导入结果会显示跳过数量)。
@@ -58,7 +58,7 @@ dsh --profile web
58
58
  dsh plugin --profile web remove dsh-mcp-skills
59
59
  ```
60
60
 
61
- 如不再需要,删除 profile `cordis.patch.yml` 中生成的 `mcp-*` 行及 `$DSH_HOME/mcp-servers.yaml`。
61
+ 如不再需要,删除 profile `cordis.patch.yml` 中生成的 `mcp-*` 行即可。
62
62
 
63
63
 
64
64
 
package/images/mcp.png CHANGED
Binary file
package/images/skills.png CHANGED
Binary file
package/lib/client.js CHANGED
@@ -33,7 +33,7 @@ window.__ModuleLoader__.load({
33
33
  // ── styles ────────────────────────────────────────────────────────────────
34
34
  var manageStylesId = 'dsh-mcp-skills/manage.module.css'
35
35
  var manageCss =
36
- '.smg_section{box-sizing:border-box;flex-direction:column;gap:12px;width:100%;min-height:0;display:flex}.smg_toolbar{flex:none;justify-content:space-between;align-items:center;gap:8px;display:flex}.smg_actions{flex:none;align-items:center;gap:4px;display:flex}.smg_title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:500;line-height:22px}.smg_hint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.smg_list{flex-direction:column;gap:8px;min-height:0;padding:2px;display:flex;overflow:auto}.smg_card{box-sizing:border-box;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;flex-direction:column;gap:6px;padding:10px 12px;display:flex}.smg_cardHead{flex:none;align-items:center;justify-content:space-between;gap:8px;min-width:0;display:flex}.smg_cardName{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.smg_cardDesc{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:19px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.smg_cardMeta{flex-wrap:wrap;gap:6px;align-items:center;display:flex}.smg_pill{box-sizing:border-box;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover);border-radius:999px;padding:2px 8px;font-size:11px;line-height:16px;white-space:nowrap}.smg_field{flex-direction:column;gap:4px;min-width:0;display:flex}.smg_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}.smg_input{box-sizing:border-box;width:100%;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;font-family:inherit;font-size:13px;line-height:20px}.smg_textarea{box-sizing:border-box;width:100%;min-height:110px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;font-family:inherit;font-size:13px;line-height:20px;resize:vertical}.smg_form{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;flex-direction:column;gap:10px;padding:12px;display:flex}.smg_formRow{flex:none;align-items:flex-end;gap:8px;display:flex}.smg_check{align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;display:flex}.smg_error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}.smg_notice{color:var(--dsw-alias-state-success-primary);font-size:12px;line-height:18px}'
36
+ '.smg_section{box-sizing:border-box;flex-direction:column;gap:12px;width:100%;min-height:0;display:flex}.smg_toolbar{flex:none;justify-content:space-between;align-items:center;gap:8px;display:flex}.smg_actions{flex:none;align-items:center;gap:4px;display:flex}.smg_title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:500;line-height:22px}.smg_hint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.smg_list{flex-direction:column;gap:8px;min-height:0;padding:2px;display:flex;overflow:auto}.smg_card{box-sizing:border-box;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;flex-direction:column;gap:6px;padding:10px 12px;display:flex}.smg_cardHead{flex:none;align-items:center;justify-content:space-between;gap:8px;min-width:0;display:flex}.smg_cardName{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.smg_cardDesc{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:19px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.smg_cardMeta{flex-wrap:wrap;gap:6px;align-items:center;display:flex}.smg_pill{box-sizing:border-box;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover);border-radius:999px;padding:2px 8px;font-size:11px;line-height:16px;white-space:nowrap}.smg_field{flex-direction:column;gap:4px;min-width:0;display:flex}.smg_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}.smg_input{box-sizing:border-box;width:100%;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;font-family:inherit;font-size:13px;line-height:20px}.smg_textarea{box-sizing:border-box;width:100%;min-height:110px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;font-family:inherit;font-size:13px;line-height:20px;resize:vertical}.smg_form{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;flex-direction:column;gap:10px;padding:12px;display:flex}.smg_formRow{flex:none;align-items:flex-end;gap:8px;display:flex}.smg_check{align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;display:flex}.smg_error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}.smg_notice{color:var(--dsw-alias-state-success-primary);font-size:12px;line-height:18px}.smg_capabilities{box-sizing:border-box;display:flex;flex-direction:column;gap:12px;width:100%;height:100%;min-height:0}.smg_tabs{display:flex;gap:6px;flex:none;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:8px}.smg_tab{appearance:none;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;padding:4px 10px;border-radius:8px;cursor:pointer}.smg_tab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.smg_tabActive{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-weight:500}.smg_tabBody{display:flex;flex-direction:column;flex:1;min-height:0}'
37
37
  if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(manageStylesId) + ']') === null) {
38
38
  var tag = document.createElement('style')
39
39
  tag.dataset.plugin = 'dsh-mcp-skills'
@@ -63,6 +63,11 @@ window.__ModuleLoader__.load({
63
63
  check: 'smg_check',
64
64
  error: 'smg_error',
65
65
  notice: 'smg_notice',
66
+ capabilities: 'smg_capabilities',
67
+ tabs: 'smg_tabs',
68
+ tab: 'smg_tab',
69
+ tabActive: 'smg_tabActive',
70
+ tabBody: 'smg_tabBody',
66
71
  }
67
72
 
68
73
  var { useState: useManageState, useEffect: useManageEffect, useCallback: useManageCallback, useRef: useManageRef } = react
@@ -215,7 +220,7 @@ window.__ModuleLoader__.load({
215
220
  toolCallTimeoutMs: Number(draft.toolCallTimeoutMs) || MANAGE_DEFAULT_TIMEOUT_MS,
216
221
  failOnStartupError: draft.failOnStartupError,
217
222
  ...(draft.transport === 'stdio'
218
- ? { command: draft.command, args: manageLines(draft.argsText), env: manageKeyValue(draft.envText), cwd: draft.cwd }
223
+ ? { command: draft.command, args: manageLines(draft.argsText), env: manageKeyValue(draft.envText) }
219
224
  : { url: draft.url, headers: manageHeaders(draft.headersText) }),
220
225
  }
221
226
  }
@@ -229,7 +234,6 @@ window.__ModuleLoader__.load({
229
234
  command: server.command ?? '',
230
235
  argsText: (server.args ?? []).join('\n'),
231
236
  envText: Object.entries(server.env ?? {}).map(([key, value]) => key + '=' + value).join('\n'),
232
- cwd: server.cwd ?? '',
233
237
  url: server.url ?? '',
234
238
  headersText: Object.entries(server.headers ?? {}).map(([key, value]) => manageHeaderLine(key, value)).join('\n'),
235
239
  toolCallTimeoutMs: String(server.toolCallTimeoutMs ?? MANAGE_DEFAULT_TIMEOUT_MS),
@@ -762,16 +766,6 @@ window.__ModuleLoader__.load({
762
766
  },
763
767
  }),
764
768
  }),
765
- react_jsx_runtime.jsx(ManageField, {
766
- label: t('mcp.form.cwd'),
767
- children: react_jsx_runtime.jsx('input', {
768
- className: manageStyles.input,
769
- value: draft.cwd,
770
- onChange: (event) => {
771
- setDraftField('cwd', event.target.value)
772
- },
773
- }),
774
- }),
775
769
  ],
776
770
  })
777
771
  : react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, {
@@ -900,10 +894,52 @@ window.__ModuleLoader__.load({
900
894
  })
901
895
  }
902
896
 
897
+ /**
898
+ * The "Capabilities" settings page: one nav entry, two tabs (MCP / Skills).
899
+ */
900
+ function CapabilitiesSection({ api, t }) {
901
+ var [tab, setTab] = useManageState('mcp')
902
+ var tabs = [
903
+ ['mcp', t('capabilities.tab.mcp')],
904
+ ['skills', t('capabilities.tab.skills')],
905
+ ]
906
+ return react_jsx_runtime.jsxs('div', {
907
+ className: manageStyles.capabilities,
908
+ children: [
909
+ react_jsx_runtime.jsx('div', {
910
+ className: manageStyles.tabs,
911
+ children: tabs.map((entry) =>
912
+ react_jsx_runtime.jsx(
913
+ 'button',
914
+ {
915
+ type: 'button',
916
+ className: tab === entry[0] ? manageStyles.tab + ' ' + manageStyles.tabActive : manageStyles.tab,
917
+ onClick: () => {
918
+ setTab(entry[0])
919
+ },
920
+ children: entry[1],
921
+ },
922
+ entry[0],
923
+ ),
924
+ ),
925
+ }),
926
+ react_jsx_runtime.jsx('div', {
927
+ className: manageStyles.tabBody,
928
+ children:
929
+ tab === 'mcp'
930
+ ? react_jsx_runtime.jsx(McpSection, { api: api, t: t })
931
+ : react_jsx_runtime.jsx(SkillsSection, { api: api, t: t }),
932
+ }),
933
+ ],
934
+ })
935
+ }
936
+
903
937
  // ── copy dictionaries ──────────────────────────────────────────────────────
904
938
  var MANAGE_NS = 'settings-mcp-skills'
905
939
  var manageZh = {
906
- 'skills.nav': 'Skills',
940
+ 'capabilities.nav': '能力',
941
+ 'capabilities.tab.mcp': 'MCP',
942
+ 'capabilities.tab.skills': 'Skills',
907
943
  'skills.title': 'Skills管理',
908
944
  'skills.refresh': '刷新',
909
945
  'skills.new': '导入技能',
@@ -917,7 +953,6 @@ window.__ModuleLoader__.load({
917
953
  'skills.importedNone': '未导入任何技能:{count} 个已存在或不支持。',
918
954
  'skills.removed': '已删除。',
919
955
  'skills.enabled': '允许模型调用',
920
- 'mcp.nav': 'MCP',
921
956
  'mcp.title': 'MCP 服务器',
922
957
  'mcp.refresh': '刷新',
923
958
  'mcp.new': '添加服务器',
@@ -938,7 +973,6 @@ window.__ModuleLoader__.load({
938
973
  'mcp.form.command': '命令',
939
974
  'mcp.form.args': '参数(每行一个)',
940
975
  'mcp.form.env': '环境变量(KEY=VALUE,每行一个)',
941
- 'mcp.form.cwd': '工作目录(可选)',
942
976
  'mcp.form.url': 'URL',
943
977
  'mcp.form.headers': '请求头(Name: Value,每行一个)',
944
978
  'mcp.form.timeout': '单次调用超时(毫秒)',
@@ -954,7 +988,9 @@ window.__ModuleLoader__.load({
954
988
  'mcp.removed': '已删除。',
955
989
  }
956
990
  var manageEn = {
957
- 'skills.nav': 'Skills',
991
+ 'capabilities.nav': 'Capabilities',
992
+ 'capabilities.tab.mcp': 'MCP',
993
+ 'capabilities.tab.skills': 'Skills',
958
994
  'skills.title': 'Skills Management',
959
995
  'skills.refresh': 'Refresh',
960
996
  'skills.new': 'Import skill',
@@ -968,7 +1004,6 @@ window.__ModuleLoader__.load({
968
1004
  'skills.importedNone': 'Nothing imported: {count} skill(s) already exist or are unsupported.',
969
1005
  'skills.removed': 'Removed.',
970
1006
  'skills.enabled': 'Model invocation',
971
- 'mcp.nav': 'MCP',
972
1007
  'mcp.title': 'MCP Servers',
973
1008
  'mcp.refresh': 'Refresh',
974
1009
  'mcp.new': 'Add server',
@@ -989,7 +1024,6 @@ window.__ModuleLoader__.load({
989
1024
  'mcp.form.command': 'Command',
990
1025
  'mcp.form.args': 'Arguments (one per line)',
991
1026
  'mcp.form.env': 'Environment (KEY=VALUE, one per line)',
992
- 'mcp.form.cwd': 'Working directory (optional)',
993
1027
  'mcp.form.url': 'URL',
994
1028
  'mcp.form.headers': 'Headers (Name: Value, one per line)',
995
1029
  'mcp.form.timeout': 'Per-call timeout (ms)',
@@ -1041,26 +1075,12 @@ window.__ModuleLoader__.load({
1041
1075
  ctx.slots.register(
1042
1076
  {
1043
1077
  name: 'settings.section',
1044
- id: 'skills',
1078
+ id: 'capabilities',
1045
1079
  order: 30,
1046
- label: () => t('skills.nav'),
1047
- locale: MANAGE_NS,
1048
- },
1049
- deliver(SkillsSection),
1050
- ),
1051
- )
1052
- ctx.slots.inject(
1053
- 'settings.section',
1054
- () =>
1055
- ctx.slots.register(
1056
- {
1057
- name: 'settings.section',
1058
- id: 'mcp',
1059
- order: 40,
1060
- label: () => t('mcp.nav'),
1080
+ label: () => t('capabilities.nav'),
1061
1081
  locale: MANAGE_NS,
1062
1082
  },
1063
- deliver(McpSection),
1083
+ deliver(CapabilitiesSection),
1064
1084
  ),
1065
1085
  )
1066
1086
  }
package/lib/index.js CHANGED
@@ -12,7 +12,7 @@ import { access, copyFile, mkdir, readdir, readFile, rename, rm, writeFile } fro
12
12
  import { homedir } from 'node:os'
13
13
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
- import { isMap, isSeq, parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml'
15
+ import { isMap, isSeq, parse as parseYaml, parseDocument } from 'yaml'
16
16
  import { unzipSync } from 'fflate/node'
17
17
 
18
18
  export const name = 'mcp-skills'
@@ -20,9 +20,11 @@ export const inject = []
20
20
 
21
21
  const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
22
22
  /** Kept in step with package.json (the smoke test reports it to the server). */
23
- const PLUGIN_VERSION = '0.2.3'
23
+ const PLUGIN_VERSION = '0.5.0'
24
24
  const MCP_CONFIG_FILENAME = 'mcp-servers.yaml'
25
25
  const MCP_PROFILE_ROW_PREFIX = 'mcp-'
26
+ /** The package every MCP row mounts, whether managed here or by another tool. */
27
+ const MCP_CLIENT_PACKAGE = '@deepseek-ai/dsh-mcp-client'
26
28
  /** Same grammar as @deepseek-ai/dsh-mcp-client's SERVER_NAME_PATTERN. */
27
29
  const MCP_SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/
28
30
  const ROUTE_BASE = '/dsh-mcp-skills'
@@ -131,6 +133,24 @@ function isRecord(value) {
131
133
  return value !== null && typeof value === 'object' && !Array.isArray(value)
132
134
  }
133
135
 
136
+ /**
137
+ * Resolve one skill-root entry to the file holding its definition.
138
+ *
139
+ * Entries are frequently symlinks (a skill linked from a shared directory such
140
+ * as `~/.cc-switch/skills`), and a Dirent does not follow links: `isDirectory()`
141
+ * and `isFile()` are both false for them. Decide by what is actually readable,
142
+ * so directory bundles, flat files, and either kind of link all resolve.
143
+ */
144
+ async function resolveSkillFile(root, entry) {
145
+ const bundle = join(root, entry.name, 'SKILL.md')
146
+ if (await pathExists(bundle)) return bundle
147
+ if (entry.name.endsWith('.md') || entry.isSymbolicLink()) {
148
+ const flat = join(root, entry.name)
149
+ if (await pathExists(flat)) return flat
150
+ }
151
+ return undefined
152
+ }
153
+
134
154
  /** Enumerate the user-owned skill catalog. */
135
155
  async function listUserSkills() {
136
156
  const root = skillsRoot()
@@ -144,17 +164,13 @@ async function listUserSkills() {
144
164
  const skills = []
145
165
  for (const entry of entries) {
146
166
  if (entry.name === '.system') continue
147
- const path = entry.isDirectory()
148
- ? join(root, entry.name, 'SKILL.md')
149
- : entry.isFile() && entry.name.endsWith('.md')
150
- ? join(root, entry.name)
151
- : undefined
167
+ const path = await resolveSkillFile(root, entry)
152
168
  if (path === undefined) continue
153
169
  let raw
154
170
  try {
155
171
  raw = await readFile(path, 'utf8')
156
172
  } catch (error) {
157
- if (error.code === 'ENOENT' || error.code === 'ENOTDIR') continue
173
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EISDIR') continue
158
174
  throw error
159
175
  }
160
176
  const parsed = parseSkillFrontmatter(raw)
@@ -346,23 +362,39 @@ async function setSkillEnabled(name, enabled) {
346
362
  return name
347
363
  }
348
364
 
349
- /** Read the managed MCP server document; a missing file is an empty catalog. */
350
- async function readMcpServers() {
351
- const path = join(dshHome(), MCP_CONFIG_FILENAME)
365
+ /**
366
+ * Read every MCP client row from the profile patch — the single source of
367
+ * truth. Every such row is editable here, whichever tool wrote it.
368
+ */
369
+ async function readMcpServers(ctx) {
370
+ const patchPath = join(profileDirectory(ctx), 'cordis.patch.yml')
371
+ let raw
352
372
  try {
353
- const parsed = parseYaml(await readFile(path, 'utf8'))
354
- if (!isRecord(parsed)) return []
355
- return Array.isArray(parsed.servers) ? parsed.servers.filter(isRecord) : []
373
+ raw = await readFile(patchPath, 'utf8')
356
374
  } catch (error) {
357
375
  if (error.code === 'ENOENT') return []
358
376
  throw error
359
377
  }
378
+ const doc = parseDocument(raw, { logLevel: 'silent' })
379
+ if (doc.errors.length > 0 || !isSeq(doc.contents)) return []
380
+
381
+ const rows = []
382
+ const collect = (nodes) => {
383
+ for (const node of nodes) {
384
+ if (!isMap(node)) continue
385
+ const insert = node.get('insert')
386
+ if (isSeq(insert)) collect(insert.items)
387
+ else if (isMcpClientNode(node)) rows.push(node)
388
+ }
389
+ }
390
+ collect(doc.contents.items)
391
+
392
+ return rows.map(serverFromRow).filter((server) => server !== undefined)
360
393
  }
361
394
 
362
- /** Resolve one managed server by name. */
363
- async function findMcpServer(name) {
364
- const servers = await readMcpServers()
365
- return servers.find((s) => s.name === name)
395
+ /** Resolve one server by name. */
396
+ async function findMcpServer(ctx, name) {
397
+ return (await readMcpServers(ctx)).find((server) => server.name === name)
366
398
  }
367
399
 
368
400
  /** Validate one submitted server definition and normalize it for storage. */
@@ -395,18 +427,55 @@ function normalizeMcpServer(server) {
395
427
  toolCallTimeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_TOOL_CALL_TIMEOUT_MS,
396
428
  failOnStartupError: server.failOnStartupError === true,
397
429
  ...(server.transport === 'stdio'
398
- ? { command: server.command, args: Array.isArray(server.args) ? server.args : [], env: isRecord(server.env) ? server.env : {}, cwd: typeof server.cwd === 'string' ? server.cwd : '' }
430
+ ? { command: server.command, args: Array.isArray(server.args) ? server.args : [], env: isRecord(server.env) ? server.env : {} }
399
431
  : { url: server.url, headers: isRecord(server.headers) ? server.headers : {} }),
400
432
  }
401
433
  }
402
434
 
403
435
  /** Persist the managed document and regenerate the profile patch rows. */
436
+ /** Regenerate the profile patch rows for the given server list. */
404
437
  async function writeMcpServers(ctx, servers) {
405
- const path = join(dshHome(), MCP_CONFIG_FILENAME)
406
- await writeFileAtomic(path, stringifyYaml({ servers }, { lineWidth: 0 }))
407
438
  await syncMcpProfilePatch(ctx, servers)
408
439
  }
409
440
 
441
+ /**
442
+ * One-time import of the retired `mcp-servers.yaml` manifest into the profile
443
+ * patch. Runs on activation and removes the file once its servers are in.
444
+ */
445
+ async function migrateMcpManifest(ctx) {
446
+ const manifestPath = join(dshHome(), MCP_CONFIG_FILENAME)
447
+ let raw
448
+ try {
449
+ raw = await readFile(manifestPath, 'utf8')
450
+ } catch (error) {
451
+ if (error.code === 'ENOENT') return
452
+ throw error
453
+ }
454
+
455
+ let legacy = []
456
+ try {
457
+ const parsed = parseYaml(raw)
458
+ if (isRecord(parsed) && Array.isArray(parsed.servers)) legacy = parsed.servers.filter(isRecord)
459
+ } catch {
460
+ // An unreadable manifest is still removed; the patch remains the truth.
461
+ }
462
+
463
+ const existing = await readMcpServers(ctx)
464
+ const known = new Set(existing.map((server) => server.name))
465
+ const imported = []
466
+ for (const entry of legacy) {
467
+ if (typeof entry.name !== 'string' || known.has(entry.name)) continue
468
+ try {
469
+ imported.push(normalizeMcpServer(entry))
470
+ } catch {
471
+ // A malformed legacy entry is skipped rather than blocking the import.
472
+ }
473
+ }
474
+ if (imported.length > 0) await writeMcpServers(ctx, [...existing, ...imported])
475
+ await rm(manifestPath, { force: true })
476
+ console.log(`[mcp-skills] migrated ${imported.length} server(s) from mcp-servers.yaml into the profile patch`)
477
+ }
478
+
410
479
  /** Executable directories a GUI-launched harness commonly lacks. */
411
480
  const EXTRA_PATH_DIRS = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin']
412
481
 
@@ -432,16 +501,20 @@ function serverEnv(env) {
432
501
  */
433
502
  function serverConnection(server) {
434
503
  return server.transport === 'stdio'
435
- ? { command: server.command, args: server.args ?? [], env: serverEnv(server.env ?? {}), cwd: server.cwd || process.cwd() }
504
+ ? { command: server.command, args: server.args ?? [], env: serverEnv(server.env ?? {}) }
436
505
  : { url: server.url, headers: server.headers ?? {} }
437
506
  }
438
507
 
439
508
  /** The cordis row that mounts one server as an mcp-client instance. */
440
509
  function mcpPatchRow(server) {
441
510
  return {
442
- id: `${MCP_PROFILE_ROW_PREFIX}${server.name}`,
443
- name: '@deepseek-ai/dsh-mcp-client',
511
+ // A row written by another tool keeps its id; a new row gets the plugin's
512
+ // own `mcp-<name>` id.
513
+ id: server.id ?? `${MCP_PROFILE_ROW_PREFIX}${server.name}`,
514
+ name: MCP_CLIENT_PACKAGE,
515
+ ...(server.enabled === false ? { disabled: true } : {}),
444
516
  config: {
517
+ ...(isRecord(server.config) ? server.config : {}),
445
518
  transport: server.transport,
446
519
  serverName: server.name,
447
520
  toolCallTimeoutMs: server.toolCallTimeoutMs ?? DEFAULT_TOOL_CALL_TIMEOUT_MS,
@@ -464,15 +537,46 @@ function profileDirectory(ctx) {
464
537
  }
465
538
  }
466
539
 
467
- /** Whether one parsed YAML node is a row this plugin owns. */
468
- function isMcpRowNode(node) {
469
- if (!isMap(node)) return false
470
- const id = node.get('id')
471
- return typeof id === 'string' && id.startsWith(MCP_PROFILE_ROW_PREFIX)
540
+ /** Whether one parsed YAML node mounts an MCP client (managed or external). */
541
+ function isMcpClientNode(node) {
542
+ return isMap(node) && node.get('name') === MCP_CLIENT_PACKAGE
543
+ }
544
+
545
+ /** One MCP client row as a flat server record. */
546
+ function serverFromRow(row) {
547
+ const config = row.get('config')
548
+ // `cwd` is not configurable here: MCP children inherit the harness directory.
549
+ const { cwd: _cwd, ...values } = isMap(config) ? config.toJSON() : {}
550
+ const name = typeof values.serverName === 'string' && values.serverName !== '' ? values.serverName : undefined
551
+ if (name === undefined) return undefined
552
+ const id = typeof row.get('id') === 'string' ? row.get('id') : undefined
553
+ return {
554
+ ...(id !== undefined ? { id } : {}),
555
+ name,
556
+ enabled: row.get('disabled') !== true,
557
+ // The raw config is kept so fields this UI does not expose (reconnect,
558
+ // maxInstructionBytes, …) survive an edit round trip.
559
+ config: values,
560
+ transport: values.transport,
561
+ toolCallTimeoutMs: typeof values.toolCallTimeoutMs === 'number' ? values.toolCallTimeoutMs : DEFAULT_TOOL_CALL_TIMEOUT_MS,
562
+ failOnStartupError: values.failOnStartupError === true,
563
+ ...(values.transport === 'stdio'
564
+ ? {
565
+ command: typeof values.command === 'string' ? values.command : '',
566
+ args: Array.isArray(values.args) ? values.args : [],
567
+ env: isRecord(values.env) ? values.env : {},
568
+ }
569
+ : { url: typeof values.url === 'string' ? values.url : '', headers: isRecord(values.headers) ? values.headers : {} }),
570
+ }
472
571
  }
473
572
 
474
573
  /**
475
- * Rewrite the profile's `cordis.patch.yml`, replacing every `mcp-*` row.
574
+ * Rewrite the profile's `cordis.patch.yml`, regenerating every row that mounts
575
+ * an MCP client.
576
+ *
577
+ * Every such row is managed here, whatever tool wrote it: the plugin is the
578
+ * single editor for this slice of the patch. Row ids are preserved, so a row
579
+ * owned by another tool keeps its identity while its config follows the list.
476
580
  *
477
581
  * The profile patch is user-owned and boot-critical, so this edits the parsed
478
582
  * document node by node instead of re-serializing a plain object: comments,
@@ -497,18 +601,22 @@ async function syncMcpProfilePatch(ctx, servers) {
497
601
  if (doc.contents === null) doc.contents = doc.createNode([])
498
602
  if (!isSeq(doc.contents)) throw new ManageError('profile-patch-invalid', 'the profile patch is expected to be a top-level YAML array')
499
603
 
500
- // Drop every row this plugin owns, both as a bare entry and inside an
501
- // `insert` list. Walking backwards keeps the indices valid.
604
+ // Rows this pass owns: every row that mounts an MCP client, whatever tool
605
+ // wrote it. The plugin is the single editor for this slice of the patch.
606
+ const owned = (node) => isMcpClientNode(node)
607
+
608
+ // Drop those rows, both as bare entries and inside an `insert` list.
609
+ // Walking backwards keeps the indices valid.
502
610
  const items = doc.contents.items
503
611
  for (let index = items.length - 1; index >= 0; index -= 1) {
504
612
  const entry = items[index]
505
613
  if (!isMap(entry)) continue
506
614
  const insert = entry.get('insert')
507
615
  if (!isSeq(insert)) {
508
- if (isMcpRowNode(entry)) doc.delete(index)
616
+ if (owned(entry)) doc.delete(index)
509
617
  continue
510
618
  }
511
- const kept = insert.items.filter((row) => !isMcpRowNode(row))
619
+ const kept = insert.items.filter((row) => !owned(row))
512
620
  if (kept.length === 0) doc.delete(index)
513
621
  else if (kept.length !== insert.items.length) insert.items = kept
514
622
  }
@@ -673,24 +781,35 @@ function routes(ctx) {
673
781
  [`${ROUTE_BASE}/skill.install`]: async (payload) => ({ value: await importUserSkills(payload) }),
674
782
  [`${ROUTE_BASE}/skill.remove`]: async (payload) => ({ value: { removed: true, name: await removeUserSkill(payload.name) } }),
675
783
  [`${ROUTE_BASE}/skill.enable`]: async (payload) => ({ value: { name: await setSkillEnabled(payload.name, payload.enabled === true) } }),
676
- [`${ROUTE_BASE}/mcp.list`]: async () => ({ value: { servers: await readMcpServers(), file: join(dshHome(), MCP_CONFIG_FILENAME) } }),
784
+ [`${ROUTE_BASE}/mcp.list`]: async () => {
785
+ return { value: { servers: await readMcpServers(ctx) } }
786
+ },
677
787
  [`${ROUTE_BASE}/mcp.upsert`]: async (payload) => {
678
788
  const server = normalizeMcpServer(payload.server)
679
- const existing = await readMcpServers()
680
- const index = existing.findIndex((s) => s.name === server.name)
681
- const next = index === -1 ? [...existing, server] : existing.map((s, i) => (i === index ? server : s))
789
+ const existing = await readMcpServers(ctx)
790
+ const previous = existing.find((entry) => entry.name === server.name)
791
+ // Keep the row id and any config field this form does not expose.
792
+ const merged = previous === undefined
793
+ ? server
794
+ : {
795
+ ...previous,
796
+ ...server,
797
+ config: { ...(isRecord(previous.config) ? previous.config : {}), ...(isRecord(server.config) ? server.config : {}) },
798
+ }
799
+ const index = existing.findIndex((entry) => entry.name === server.name)
800
+ const next = index === -1 ? [...existing, merged] : existing.map((entry, i) => (i === index ? merged : entry))
682
801
  await writeMcpServers(ctx, next)
683
802
  return { value: { name: server.name } }
684
803
  },
685
804
  [`${ROUTE_BASE}/mcp.remove`]: async (payload) => {
686
- const servers = await readMcpServers()
687
- const next = servers.filter((s) => s.name !== payload.name)
688
- if (next.length === servers.length) throw new ManageError('mcp-not-found', `MCP server "${payload.name}" not found`)
805
+ const existing = await readMcpServers(ctx)
806
+ const next = existing.filter((entry) => entry.name !== payload.name)
807
+ if (next.length === existing.length) throw new ManageError('mcp-not-found', `MCP server "${payload.name}" not found`)
689
808
  await writeMcpServers(ctx, next)
690
809
  return { value: { removed: true } }
691
810
  },
692
811
  [`${ROUTE_BASE}/mcp.test`]: async (payload) => {
693
- const server = await findMcpServer(payload.name)
812
+ const server = await findMcpServer(ctx, payload.name)
694
813
  if (server === undefined) throw new ManageError('mcp-not-found', `MCP server "${payload.name}" not found`)
695
814
  const tools = await testMcpConnection(server)
696
815
  return { value: { ok: true, tools } }
@@ -754,6 +873,8 @@ function dispatch(ctx) {
754
873
  */
755
874
  export function apply(ctx) {
756
875
  if (typeof ctx.inject !== 'function') return
876
+ // One-time: fold the retired manifest file into the profile patch.
877
+ void migrateMcpManifest(ctx).catch((error) => console.error(`[mcp-skills] manifest migration skipped: ${error}`))
757
878
  ctx.inject(['webServer'], (scope) => {
758
879
  try {
759
880
  scope.webServer.register({ kind: 'prefix', path: ROUTE_BASE, handler: dispatch(ctx) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp-skills",
3
- "version": "0.2.3",
3
+ "version": "0.5.0",
4
4
  "description": "DeepSeek Harness web plugin: settings pages for managing user skills and MCP servers",
5
5
  "type": "module",
6
6
  "repository": {