dsh-mcp-skills 0.2.2 → 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
@@ -7,11 +7,12 @@
7
7
  // @deepseek-ai packages so out-of-tree resolution stays reliable (the modlens
8
8
  // precedent); `yaml` and `fflate` are runtime dependencies, and the MCP SDK is
9
9
  // an optional peer the host installation already provides (see loadMcpSdk).
10
+ import { existsSync } from 'node:fs'
10
11
  import { access, copyFile, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
11
12
  import { homedir } from 'node:os'
12
13
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
13
14
  import { fileURLToPath } from 'node:url'
14
- import { isMap, isSeq, parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml'
15
+ import { isMap, isSeq, parse as parseYaml, parseDocument } from 'yaml'
15
16
  import { unzipSync } from 'fflate/node'
16
17
 
17
18
  export const name = 'mcp-skills'
@@ -19,9 +20,11 @@ export const inject = []
19
20
 
20
21
  const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
21
22
  /** Kept in step with package.json (the smoke test reports it to the server). */
22
- const PLUGIN_VERSION = '0.2.2'
23
+ const PLUGIN_VERSION = '0.5.0'
23
24
  const MCP_CONFIG_FILENAME = 'mcp-servers.yaml'
24
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'
25
28
  /** Same grammar as @deepseek-ai/dsh-mcp-client's SERVER_NAME_PATTERN. */
26
29
  const MCP_SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/
27
30
  const ROUTE_BASE = '/dsh-mcp-skills'
@@ -130,6 +133,24 @@ function isRecord(value) {
130
133
  return value !== null && typeof value === 'object' && !Array.isArray(value)
131
134
  }
132
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
+
133
154
  /** Enumerate the user-owned skill catalog. */
134
155
  async function listUserSkills() {
135
156
  const root = skillsRoot()
@@ -143,17 +164,13 @@ async function listUserSkills() {
143
164
  const skills = []
144
165
  for (const entry of entries) {
145
166
  if (entry.name === '.system') continue
146
- const path = entry.isDirectory()
147
- ? join(root, entry.name, 'SKILL.md')
148
- : entry.isFile() && entry.name.endsWith('.md')
149
- ? join(root, entry.name)
150
- : undefined
167
+ const path = await resolveSkillFile(root, entry)
151
168
  if (path === undefined) continue
152
169
  let raw
153
170
  try {
154
171
  raw = await readFile(path, 'utf8')
155
172
  } catch (error) {
156
- if (error.code === 'ENOENT' || error.code === 'ENOTDIR') continue
173
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EISDIR') continue
157
174
  throw error
158
175
  }
159
176
  const parsed = parseSkillFrontmatter(raw)
@@ -345,23 +362,39 @@ async function setSkillEnabled(name, enabled) {
345
362
  return name
346
363
  }
347
364
 
348
- /** Read the managed MCP server document; a missing file is an empty catalog. */
349
- async function readMcpServers() {
350
- 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
351
372
  try {
352
- const parsed = parseYaml(await readFile(path, 'utf8'))
353
- if (!isRecord(parsed)) return []
354
- return Array.isArray(parsed.servers) ? parsed.servers.filter(isRecord) : []
373
+ raw = await readFile(patchPath, 'utf8')
355
374
  } catch (error) {
356
375
  if (error.code === 'ENOENT') return []
357
376
  throw error
358
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)
359
393
  }
360
394
 
361
- /** Resolve one managed server by name. */
362
- async function findMcpServer(name) {
363
- const servers = await readMcpServers()
364
- 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)
365
398
  }
366
399
 
367
400
  /** Validate one submitted server definition and normalize it for storage. */
@@ -394,34 +427,94 @@ function normalizeMcpServer(server) {
394
427
  toolCallTimeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_TOOL_CALL_TIMEOUT_MS,
395
428
  failOnStartupError: server.failOnStartupError === true,
396
429
  ...(server.transport === 'stdio'
397
- ? { 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 : {} }
398
431
  : { url: server.url, headers: isRecord(server.headers) ? server.headers : {} }),
399
432
  }
400
433
  }
401
434
 
402
435
  /** Persist the managed document and regenerate the profile patch rows. */
436
+ /** Regenerate the profile patch rows for the given server list. */
403
437
  async function writeMcpServers(ctx, servers) {
404
- const path = join(dshHome(), MCP_CONFIG_FILENAME)
405
- await writeFileAtomic(path, stringifyYaml({ servers }, { lineWidth: 0 }))
406
438
  await syncMcpProfilePatch(ctx, servers)
407
439
  }
408
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
+
479
+ /** Executable directories a GUI-launched harness commonly lacks. */
480
+ const EXTRA_PATH_DIRS = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin']
481
+
482
+ /**
483
+ * The environment one stdio server is spawned with.
484
+ *
485
+ * MCP servers are spawned by `@deepseek-ai/dsh-mcp-client` from the harness
486
+ * process environment, and a harness launched from a desktop icon inherits a
487
+ * minimal PATH (macOS gives just `/usr/bin:/bin:/usr/sbin:/sbin`). A configured
488
+ * `npx` / `uvx` then fails with ENOENT even though the user's shell resolves it,
489
+ * so the well-known executable directories are prepended when they exist.
490
+ * An explicit `PATH` in the server config still wins.
491
+ */
492
+ function serverEnv(env) {
493
+ const current = (process.env.PATH ?? '').split(':').filter((entry) => entry !== '')
494
+ const missing = EXTRA_PATH_DIRS.filter((dir) => existsSync(dir) && !current.includes(dir))
495
+ return { ...(missing.length > 0 ? { PATH: [...missing, ...current].join(':') } : {}), ...env }
496
+ }
497
+
409
498
  /**
410
499
  * The transport-shaped fields of one managed server, shared by the generated
411
500
  * patch row and the connection smoke test so both always agree.
412
501
  */
413
502
  function serverConnection(server) {
414
503
  return server.transport === 'stdio'
415
- ? { command: server.command, args: server.args ?? [], env: server.env ?? {}, cwd: server.cwd || process.cwd() }
504
+ ? { command: server.command, args: server.args ?? [], env: serverEnv(server.env ?? {}) }
416
505
  : { url: server.url, headers: server.headers ?? {} }
417
506
  }
418
507
 
419
508
  /** The cordis row that mounts one server as an mcp-client instance. */
420
509
  function mcpPatchRow(server) {
421
510
  return {
422
- id: `${MCP_PROFILE_ROW_PREFIX}${server.name}`,
423
- 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 } : {}),
424
516
  config: {
517
+ ...(isRecord(server.config) ? server.config : {}),
425
518
  transport: server.transport,
426
519
  serverName: server.name,
427
520
  toolCallTimeoutMs: server.toolCallTimeoutMs ?? DEFAULT_TOOL_CALL_TIMEOUT_MS,
@@ -444,15 +537,46 @@ function profileDirectory(ctx) {
444
537
  }
445
538
  }
446
539
 
447
- /** Whether one parsed YAML node is a row this plugin owns. */
448
- function isMcpRowNode(node) {
449
- if (!isMap(node)) return false
450
- const id = node.get('id')
451
- 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
+ }
452
571
  }
453
572
 
454
573
  /**
455
- * 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.
456
580
  *
457
581
  * The profile patch is user-owned and boot-critical, so this edits the parsed
458
582
  * document node by node instead of re-serializing a plain object: comments,
@@ -477,18 +601,22 @@ async function syncMcpProfilePatch(ctx, servers) {
477
601
  if (doc.contents === null) doc.contents = doc.createNode([])
478
602
  if (!isSeq(doc.contents)) throw new ManageError('profile-patch-invalid', 'the profile patch is expected to be a top-level YAML array')
479
603
 
480
- // Drop every row this plugin owns, both as a bare entry and inside an
481
- // `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.
482
610
  const items = doc.contents.items
483
611
  for (let index = items.length - 1; index >= 0; index -= 1) {
484
612
  const entry = items[index]
485
613
  if (!isMap(entry)) continue
486
614
  const insert = entry.get('insert')
487
615
  if (!isSeq(insert)) {
488
- if (isMcpRowNode(entry)) doc.delete(index)
616
+ if (owned(entry)) doc.delete(index)
489
617
  continue
490
618
  }
491
- const kept = insert.items.filter((row) => !isMcpRowNode(row))
619
+ const kept = insert.items.filter((row) => !owned(row))
492
620
  if (kept.length === 0) doc.delete(index)
493
621
  else if (kept.length !== insert.items.length) insert.items = kept
494
622
  }
@@ -653,24 +781,35 @@ function routes(ctx) {
653
781
  [`${ROUTE_BASE}/skill.install`]: async (payload) => ({ value: await importUserSkills(payload) }),
654
782
  [`${ROUTE_BASE}/skill.remove`]: async (payload) => ({ value: { removed: true, name: await removeUserSkill(payload.name) } }),
655
783
  [`${ROUTE_BASE}/skill.enable`]: async (payload) => ({ value: { name: await setSkillEnabled(payload.name, payload.enabled === true) } }),
656
- [`${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
+ },
657
787
  [`${ROUTE_BASE}/mcp.upsert`]: async (payload) => {
658
788
  const server = normalizeMcpServer(payload.server)
659
- const existing = await readMcpServers()
660
- const index = existing.findIndex((s) => s.name === server.name)
661
- 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))
662
801
  await writeMcpServers(ctx, next)
663
802
  return { value: { name: server.name } }
664
803
  },
665
804
  [`${ROUTE_BASE}/mcp.remove`]: async (payload) => {
666
- const servers = await readMcpServers()
667
- const next = servers.filter((s) => s.name !== payload.name)
668
- 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`)
669
808
  await writeMcpServers(ctx, next)
670
809
  return { value: { removed: true } }
671
810
  },
672
811
  [`${ROUTE_BASE}/mcp.test`]: async (payload) => {
673
- const server = await findMcpServer(payload.name)
812
+ const server = await findMcpServer(ctx, payload.name)
674
813
  if (server === undefined) throw new ManageError('mcp-not-found', `MCP server "${payload.name}" not found`)
675
814
  const tools = await testMcpConnection(server)
676
815
  return { value: { ok: true, tools } }
@@ -734,6 +873,8 @@ function dispatch(ctx) {
734
873
  */
735
874
  export function apply(ctx) {
736
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}`))
737
878
  ctx.inject(['webServer'], (scope) => {
738
879
  try {
739
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.2",
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": {