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 +5 -5
- package/images/mcp.png +0 -0
- package/images/skills.png +0 -0
- package/lib/client.js +56 -36
- package/lib/index.js +183 -42
- package/package.json +1 -1
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
|
-
|
|
12
|
+
<img src="images/skills.png" alt="Skills 页面" style="zoom:33%;" />
|
|
13
13
|
|
|
14
14
|
**MCP 页面**
|
|
15
15
|
|
|
16
|
-
|
|
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
|
|
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-*`
|
|
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)
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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: '
|
|
1078
|
+
id: 'capabilities',
|
|
1045
1079
|
order: 30,
|
|
1046
|
-
label: () => t('
|
|
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(
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
|
362
|
-
async function findMcpServer(name) {
|
|
363
|
-
|
|
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 : {}
|
|
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 ?? {}
|
|
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
|
|
423
|
-
name
|
|
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
|
|
448
|
-
function
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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`,
|
|
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
|
-
//
|
|
481
|
-
//
|
|
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 (
|
|
616
|
+
if (owned(entry)) doc.delete(index)
|
|
489
617
|
continue
|
|
490
618
|
}
|
|
491
|
-
const kept = insert.items.filter((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 () =>
|
|
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
|
|
661
|
-
|
|
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
|
|
667
|
-
const next =
|
|
668
|
-
if (next.length ===
|
|
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) })
|