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 +5 -5
- package/images/mcp.png +0 -0
- package/images/skills.png +0 -0
- package/lib/client.js +56 -36
- package/lib/index.js +163 -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
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
|
363
|
-
async function findMcpServer(name) {
|
|
364
|
-
|
|
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 : {}
|
|
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 ?? {})
|
|
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
|
|
443
|
-
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 } : {}),
|
|
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
|
|
468
|
-
function
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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`,
|
|
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
|
-
//
|
|
501
|
-
//
|
|
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 (
|
|
616
|
+
if (owned(entry)) doc.delete(index)
|
|
509
617
|
continue
|
|
510
618
|
}
|
|
511
|
-
const kept = insert.items.filter((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 () =>
|
|
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
|
|
681
|
-
|
|
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
|
|
687
|
-
const next =
|
|
688
|
-
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`)
|
|
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) })
|