@civilization/deepseek-harness-skill-mcp 0.1.6 → 0.1.8
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 +17 -7
- package/lib/client.js +10 -6
- package/package.json +3 -2
- package/src/client/index.jsx +2 -2
- package/src/client/locales.js +4 -4
- package/src/index.js +14 -3
- package/src/mcp-sse.js +122 -0
- package/src/store.js +12 -3
- package/src/web.js +67 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web 界面提供 Skill 路径和 MCP 服务器管理。
|
|
4
4
|
|
|
5
|
-
安装后,“设置”中会出现 **Skill 管理** 和 **MCP 管理** 两个页面,同时向 Agent 注册 `extensions_*` 管理工具。插件使用 Harness 自带的 Skill filesystem provider 与 MCP client
|
|
5
|
+
安装后,“设置”中会出现 **Skill 管理** 和 **MCP 管理** 两个页面,同时向 Agent 注册 `extensions_*` 管理工具。插件使用 Harness 自带的 Skill filesystem provider 与 MCP client,并为旧版 HTTP+SSE 服务提供兼容客户端;配置写入当前 `web` profile 的 `cordis.patch.yml`。
|
|
6
6
|
|
|
7
7
|
## 通过 dsh 安装
|
|
8
8
|
|
|
@@ -71,10 +71,11 @@ Skill 页面支持:
|
|
|
71
71
|
|
|
72
72
|
## MCP 管理
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
支持三种 MCP 配置类型:
|
|
75
75
|
|
|
76
76
|
- `stdio`:执行本地命令并通过标准输入输出通信
|
|
77
|
-
- `
|
|
77
|
+
- `streamable-http`:连接推荐的 Streamable HTTP MCP 地址
|
|
78
|
+
- `sse`:兼容使用独立 `/sse` 与 `/messages` 端点的旧版 MCP 服务
|
|
78
79
|
|
|
79
80
|
stdio 示例:
|
|
80
81
|
|
|
@@ -96,13 +97,22 @@ HTTP 示例:
|
|
|
96
97
|
}
|
|
97
98
|
```
|
|
98
99
|
|
|
100
|
+
旧版 SSE 示例:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"transport": "sse",
|
|
105
|
+
"url": "http://127.0.0.1:9000/sse"
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
99
109
|
服务器卡片使用状态灯显示禁用、等待发现工具或工具可用,并可展开查看实际注册的工具列表。MCP 配置可以编辑或删除,编辑时保留服务器 id 与启用状态。新服务器默认禁用;启用 stdio 服务器会在 Agent 沙箱之外执行所配置的程序。
|
|
100
110
|
|
|
101
111
|
MCP 工具是提供给模型的工具 schema,不是用户 `/` 命令,因此不会出现在 Skill 斜杠菜单中;它们的动态状态以 MCP 页面中的“工具列表”和 `extensions_inspect` 为准。
|
|
102
112
|
|
|
103
113
|
当前版本不接受 `env`、认证 header、带凭据或 token 查询参数的 URL。请勿把密钥放进工具参数、MCP 地址或提交到仓库。
|
|
104
114
|
|
|
105
|
-
MCP 对端离线、命令启动失败或首次工具发现失败不会阻止 DSH
|
|
115
|
+
MCP 对端离线、命令启动失败或首次工具发现失败不会阻止 DSH 启动。stdio 与 Streamable HTTP 使用 Harness MCP client 的后台重连策略;旧版 SSE 当前依赖连接自身,在断线后可通过刷新配置重新加载。设置页在连接恢复并发现工具后更新状态。插件加载时也会把旧版本写入的严格启动配置迁移为非致命启动策略。
|
|
106
116
|
|
|
107
117
|
## Agent 工具
|
|
108
118
|
|
|
@@ -110,7 +120,7 @@ MCP 对端离线、命令启动失败或首次工具发现失败不会阻止 DSH
|
|
|
110
120
|
|---|---|
|
|
111
121
|
| `extensions_list` | 查看受管 Skill 路径与 MCP 配置 |
|
|
112
122
|
| `extensions_add_skill` | 添加 Skill 路径;Agent 调用时显式指定内部 id |
|
|
113
|
-
| `extensions_add_mcp` | 添加 stdio
|
|
123
|
+
| `extensions_add_mcp` | 添加 stdio、Streamable HTTP 或旧版 SSE MCP 配置 |
|
|
114
124
|
| `extensions_set_enabled` | 启用或禁用受管配置 |
|
|
115
125
|
| `extensions_update_skill` | 修改 Skill 来源路径 |
|
|
116
126
|
| `extensions_update_mcp` | 替换 MCP 连接配置并保留 id 与启用状态 |
|
|
@@ -143,9 +153,9 @@ pnpm run web
|
|
|
143
153
|
|
|
144
154
|
## 验证
|
|
145
155
|
|
|
146
|
-
自动测试覆盖配置持久化、Skill 多路径、编辑和删除、单技能内容编辑与删除、用户分组、并发 revision、Skill 创建与调用权限、MCP
|
|
156
|
+
自动测试覆盖配置持久化、Skill 多路径、编辑和删除、单技能内容编辑与删除、用户分组、并发 revision、Skill 创建与调用权限、MCP 工具发现和卸载、轮询状态、中英文词典一致性,以及 DSH `webServer` 前缀路由、OPTIONS 预检和 RPC 信封协议桥接。测试不需要模型 API key。
|
|
147
157
|
|
|
148
|
-
浏览器端 `/` 候选缓存失效兼容逻辑会使用公开的 `connection/reset`
|
|
158
|
+
浏览器端 `/` 候选缓存失效兼容逻辑会使用公开的 `connection/reset` 客户端事件;服务端通过注入 `webServer` 与 `connection` 双重挂载 `/extensions` 路由并由 Cordis effect 托管生命周期,防止动态请求掉入前端静态资源的 405 fallback。自动测试验证 Host/RPC、HTTP 桥接和浏览器 bundle 构建,但没有在本仓库内启动完整 Harness Web 做端到端菜单点击验证。外部文件改动仍依赖 filesystem provider 先完成 watcher 失效,必要时可在变更后点击管理页“刷新”。
|
|
149
159
|
|
|
150
160
|
## 发布
|
|
151
161
|
|
package/lib/client.js
CHANGED
|
@@ -110,7 +110,8 @@ var zh = {
|
|
|
110
110
|
directory: "Skill \u8DEF\u5F84",
|
|
111
111
|
transport: "\u914D\u7F6E\u7C7B\u578B",
|
|
112
112
|
stdio: "stdio",
|
|
113
|
-
http: "HTTP",
|
|
113
|
+
http: "Streamable HTTP",
|
|
114
|
+
sse: "SSE\uFF08\u65E7\u7248\u517C\u5BB9\uFF09",
|
|
114
115
|
command: "\u547D\u4EE4",
|
|
115
116
|
args: "\u53C2\u6570\uFF08\u6BCF\u884C\u4E00\u4E2A\uFF09",
|
|
116
117
|
cwd: "\u5DE5\u4F5C\u76EE\u5F55\uFF08\u53EF\u9009\uFF09",
|
|
@@ -139,7 +140,7 @@ var zh = {
|
|
|
139
140
|
failed: "\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u8F93\u5165\u3001\u8FDE\u63A5\u6216\u670D\u52A1\u7AEF\u65E5\u5FD7\u540E\u91CD\u8BD5\u3002",
|
|
140
141
|
conflict: "\u914D\u7F6E\u5DF2\u88AB\u4FEE\u6539\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5\u3002\u672A\u63D0\u4EA4\u7684\u8868\u5355\u5185\u5BB9\u5DF2\u4FDD\u7559\u3002",
|
|
141
142
|
skillFootnote: "\u65B0\u8DEF\u5F84\u9ED8\u8BA4\u7981\u7528\u3002\u542F\u7528\u540E\uFF0CSkill provider \u4F1A\u626B\u63CF\u8BE5\u8DEF\u5F84\u4E0B\u7684\u6240\u6709\u6280\u80FD\u3002\u914D\u7F6E\u53D8\u66F4\u4F1A\u81EA\u52A8\u751F\u6548\u3002",
|
|
142
|
-
mcpFootnote: "\u65B0\u670D\u52A1\u5668\u9ED8\u8BA4\u7981\u7528\u3002\
|
|
143
|
+
mcpFootnote: "\u65B0\u670D\u52A1\u5668\u9ED8\u8BA4\u7981\u7528\u3002\u65B0\u670D\u52A1\u63A8\u8350 Streamable HTTP\uFF1BSSE \u4EC5\u7528\u4E8E\u517C\u5BB9\u65E7\u7248 /sse + /messages \u670D\u52A1\u3002\u914D\u7F6E\u53D8\u66F4\u4F1A\u81EA\u52A8\u751F\u6548\u3002",
|
|
143
144
|
secret: "\u8BF7\u52FF\u586B\u5199\u5BC6\u94A5\u6216\u5E26\u4EE4\u724C\u7684\u5730\u5740\u3002\u6B64\u7248\u672C\u6682\u4E0D\u652F\u6301\u8BA4\u8BC1\u53C2\u6570\u3002",
|
|
144
145
|
tools: "\u5DE5\u5177\u5217\u8868",
|
|
145
146
|
status_available: "\u5DE5\u5177\u53EF\u7528",
|
|
@@ -220,7 +221,8 @@ var en = {
|
|
|
220
221
|
directory: "Skill path",
|
|
221
222
|
transport: "Type",
|
|
222
223
|
stdio: "stdio",
|
|
223
|
-
http: "HTTP",
|
|
224
|
+
http: "Streamable HTTP",
|
|
225
|
+
sse: "SSE (legacy)",
|
|
224
226
|
command: "Command",
|
|
225
227
|
args: "Arguments (one per line)",
|
|
226
228
|
cwd: "Working directory (optional)",
|
|
@@ -249,7 +251,7 @@ var en = {
|
|
|
249
251
|
failed: "Operation failed. Check input, connection or server logs and retry.",
|
|
250
252
|
conflict: "Configuration changed. Refresh and retry; your draft has been retained.",
|
|
251
253
|
skillFootnote: "New paths are disabled. Once enabled, the Skill provider scans every skill below them.",
|
|
252
|
-
mcpFootnote: "New servers are disabled.
|
|
254
|
+
mcpFootnote: "New servers are disabled. Prefer Streamable HTTP for new servers; SSE only supports legacy /sse + /messages endpoints.",
|
|
253
255
|
secret: "Do not enter secrets or token-bearing URLs. Authentication fields are not supported yet.",
|
|
254
256
|
tools: "Tools",
|
|
255
257
|
status_available: "Tools available",
|
|
@@ -945,7 +947,8 @@ function McpForm({ row, t, busy, onSave, onCancel }) {
|
|
|
945
947
|
t("transport"),
|
|
946
948
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("select", { id: `${prefix}-transport`, value: transport, onChange: (event) => setTransport(event.target.value), children: [
|
|
947
949
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "stdio", children: t("stdio") }),
|
|
948
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "streamable-http", children: t("http") })
|
|
950
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "streamable-http", children: t("http") }),
|
|
951
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "sse", children: t("sse") })
|
|
949
952
|
] })
|
|
950
953
|
] }),
|
|
951
954
|
transport === "stdio" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
@@ -982,7 +985,8 @@ function AddForm({ kind, t, busy, onSave, onCancel }) {
|
|
|
982
985
|
t("transport"),
|
|
983
986
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("select", { id: `${prefix}-transport`, value: transport, onChange: (event) => setTransport(event.target.value), children: [
|
|
984
987
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "stdio", children: t("stdio") }),
|
|
985
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "streamable-http", children: t("http") })
|
|
988
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "streamable-http", children: t("http") }),
|
|
989
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "sse", children: t("sse") })
|
|
986
990
|
] })
|
|
987
991
|
] }),
|
|
988
992
|
transport === "stdio" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@civilization/deepseek-harness-skill-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Skill path and MCP server management for DeepSeek Harness Web",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.js",
|
|
20
20
|
"./client": "./lib/client.js",
|
|
21
|
+
"./mcp-sse": "./src/mcp-sse.js",
|
|
21
22
|
"./package.json": "./package.json"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"@deepseek-ai/dsh-mcp-client": "0.1.2-rc.1",
|
|
53
54
|
"@deepseek-ai/dsh-skill-filesystem": "0.1.2-rc.1",
|
|
54
55
|
"@deepseek-ai/schemastery": "3.18.2",
|
|
56
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
55
57
|
"yaml": "^2.9.0"
|
|
56
58
|
},
|
|
57
59
|
"peerDependencies": {
|
|
@@ -63,7 +65,6 @@
|
|
|
63
65
|
"@deepseek-ai/dsh-skill": "0.1.2-rc.1",
|
|
64
66
|
"@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1",
|
|
65
67
|
"@deepseek-ai/dsh-tools": "0.1.2-rc.1",
|
|
66
|
-
"@modelcontextprotocol/sdk": "1.29.0",
|
|
67
68
|
"esbuild": "^0.28.2",
|
|
68
69
|
"react": "^19.2.8"
|
|
69
70
|
}
|
package/src/client/index.jsx
CHANGED
|
@@ -356,7 +356,7 @@ function McpForm({ row, t, busy, onSave, onCancel }) {
|
|
|
356
356
|
void onSave({ id: row?.id ?? data.id, configuration })
|
|
357
357
|
}}><fieldset disabled={busy}>{!row && field('id')}
|
|
358
358
|
<label htmlFor={`${prefix}-transport`}>{t('transport')}<select id={`${prefix}-transport`} value={transport} onChange={event => setTransport(event.target.value)}>
|
|
359
|
-
<option value="stdio">{t('stdio')}</option><option value="streamable-http">{t('http')}</option></select></label>
|
|
359
|
+
<option value="stdio">{t('stdio')}</option><option value="streamable-http">{t('http')}</option><option value="sse">{t('sse')}</option></select></label>
|
|
360
360
|
{transport === 'stdio' ? <>{field('command', true, false, initial.command)}{field('args', false, true, (initial.args ?? []).join('\n'))}{field('cwd', false, false, initial.cwd)}</> : field('url', true, false, initial.url)}
|
|
361
361
|
<p>{t('secret')}</p>
|
|
362
362
|
<div className="dsh-ext-actions"><button type="button" onClick={onCancel}>{t('cancel')}</button><button type="submit">{t(busy ? 'saving' : row ? 'saveChanges' : 'save')}</button></div>
|
|
@@ -380,7 +380,7 @@ function AddForm({ kind, t, busy, onSave, onCancel }) {
|
|
|
380
380
|
void onSave(args)
|
|
381
381
|
}}><fieldset disabled={busy}>{kind === 'skill' ? field('directory') : <>{field('id')}
|
|
382
382
|
<label htmlFor={`${prefix}-transport`}>{t('transport')}<select id={`${prefix}-transport`} value={transport} onChange={event => setTransport(event.target.value)}>
|
|
383
|
-
<option value="stdio">{t('stdio')}</option><option value="streamable-http">{t('http')}</option></select></label>
|
|
383
|
+
<option value="stdio">{t('stdio')}</option><option value="streamable-http">{t('http')}</option><option value="sse">{t('sse')}</option></select></label>
|
|
384
384
|
{transport === 'stdio' ? <>{field('command')}{field('args', false, true)}{field('cwd', false)}</> : field('url')}
|
|
385
385
|
<p>{t('secret')}</p>
|
|
386
386
|
</>}
|
package/src/client/locales.js
CHANGED
|
@@ -5,7 +5,7 @@ export const zh = {
|
|
|
5
5
|
skills: 'Skill 路径', mcp: 'MCP 服务器', refresh: '刷新', add: '添加路径', addMcp: '添加服务器', newSkill: '新建 Skill',
|
|
6
6
|
emptySkills: '还没有配置 Skill 路径', emptySkillsHint: '指定一个本地 skills 目录,目录下的各个子目录就是技能。',
|
|
7
7
|
emptyMcp: '还没有添加 MCP 服务器', emptyMcpHint: '通过本地命令或 HTTP 地址连接外部工具。',
|
|
8
|
-
id: '标识', directory: 'Skill 路径', transport: '配置类型', stdio: 'stdio', http: 'HTTP',
|
|
8
|
+
id: '标识', directory: 'Skill 路径', transport: '配置类型', stdio: 'stdio', http: 'Streamable HTTP', sse: 'SSE(旧版兼容)',
|
|
9
9
|
command: '命令', args: '参数(每行一个)', cwd: '工作目录(可选)', url: '服务器地址',
|
|
10
10
|
cancel: '取消', save: '添加', saveChanges: '保存修改', create: '创建', edit: '编辑', delete: '删除', confirmDelete: '确认删除', deleting: '删除中…', saving: '保存中…', loading: '加载中…',
|
|
11
11
|
deleteExtensionTitle: '移除 Skill 来源', deleteMcpTitle: '删除 MCP 服务器', deleteSkillTitle: '永久删除 Skill',
|
|
@@ -15,7 +15,7 @@ export const zh = {
|
|
|
15
15
|
saved: '更改已保存。', failed: '操作失败,请检查输入、连接或服务端日志后重试。',
|
|
16
16
|
conflict: '配置已被修改,请刷新后重试。未提交的表单内容已保留。',
|
|
17
17
|
skillFootnote: '新路径默认禁用。启用后,Skill provider 会扫描该路径下的所有技能。配置变更会自动生效。',
|
|
18
|
-
mcpFootnote: '
|
|
18
|
+
mcpFootnote: '新服务器默认禁用。新服务推荐 Streamable HTTP;SSE 仅用于兼容旧版 /sse + /messages 服务。配置变更会自动生效。',
|
|
19
19
|
secret: '请勿填写密钥或带令牌的地址。此版本暂不支持认证参数。',
|
|
20
20
|
tools: '工具列表', status_available: '工具可用', status_waiting: '等待发现工具', status_disabled: '已禁用',
|
|
21
21
|
skillHint: '启用后由 Skill provider 扫描;每个配置项对应一个包含多个技能的目录。',
|
|
@@ -43,7 +43,7 @@ export const en = {
|
|
|
43
43
|
skills: 'Skill paths', mcp: 'MCP servers', refresh: 'Refresh', add: 'Add path', addMcp: 'Add server', newSkill: 'New Skill',
|
|
44
44
|
emptySkills: 'No Skill paths configured', emptySkillsHint: 'Choose a local skills directory whose children contain the individual skills.',
|
|
45
45
|
emptyMcp: 'No MCP servers yet', emptyMcpHint: 'Connect external tools with a local command or HTTP endpoint.',
|
|
46
|
-
id: 'Identifier', directory: 'Skill path', transport: 'Type', stdio: 'stdio', http: 'HTTP',
|
|
46
|
+
id: 'Identifier', directory: 'Skill path', transport: 'Type', stdio: 'stdio', http: 'Streamable HTTP', sse: 'SSE (legacy)',
|
|
47
47
|
command: 'Command', args: 'Arguments (one per line)', cwd: 'Working directory (optional)', url: 'Server URL',
|
|
48
48
|
cancel: 'Cancel', save: 'Add', saveChanges: 'Save changes', create: 'Create', edit: 'Edit', delete: 'Delete', confirmDelete: 'Delete', deleting: 'Deleting…', saving: 'Saving…', loading: 'Loading…',
|
|
49
49
|
deleteExtensionTitle: 'Remove Skill source', deleteMcpTitle: 'Delete MCP server', deleteSkillTitle: 'Permanently delete Skill',
|
|
@@ -53,7 +53,7 @@ export const en = {
|
|
|
53
53
|
saved: 'Changes saved.', failed: 'Operation failed. Check input, connection or server logs and retry.',
|
|
54
54
|
conflict: 'Configuration changed. Refresh and retry; your draft has been retained.',
|
|
55
55
|
skillFootnote: 'New paths are disabled. Once enabled, the Skill provider scans every skill below them.',
|
|
56
|
-
mcpFootnote: 'New servers are disabled.
|
|
56
|
+
mcpFootnote: 'New servers are disabled. Prefer Streamable HTTP for new servers; SSE only supports legacy /sse + /messages endpoints.',
|
|
57
57
|
secret: 'Do not enter secrets or token-bearing URLs. Authentication fields are not supported yet.',
|
|
58
58
|
tools: 'Tools', status_available: 'Tools available', status_waiting: 'Waiting for tools', status_disabled: 'Disabled',
|
|
59
59
|
skillHint: 'The Skill provider scans enabled paths; every configured path contains multiple skills.',
|
package/src/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from 'node:os'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
6
6
|
import { ExtensionStore } from './store.js'
|
|
7
|
-
import { createWebHandler } from './web.js'
|
|
7
|
+
import { createWebHandler, createWebHttpHandler } from './web.js'
|
|
8
8
|
|
|
9
9
|
export const name = 'extension-manager'
|
|
10
10
|
export const inject = ['tools', 'skills']
|
|
@@ -21,8 +21,19 @@ export async function apply(ctx, config) {
|
|
|
21
21
|
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
22
22
|
const store = new ExtensionStore(config.patchPath || join(home, 'profiles', 'web', 'cordis.patch.yml'))
|
|
23
23
|
await store.ensureNonFatalMcpStartup()
|
|
24
|
+
|
|
25
|
+
const webHandler = createWebHandler(store, ctx)
|
|
26
|
+
const httpHandler = createWebHttpHandler(store, ctx)
|
|
27
|
+
|
|
24
28
|
ctx.inject(['connection'], web => {
|
|
25
|
-
web.connection.rpc.handle('/extensions',
|
|
29
|
+
web.connection.rpc.handle('/extensions', webHandler)
|
|
30
|
+
})
|
|
31
|
+
ctx.inject(['webServer'], hostCtx => {
|
|
32
|
+
hostCtx.effect(() => hostCtx.webServer.register({
|
|
33
|
+
kind: 'prefix',
|
|
34
|
+
path: '/extensions',
|
|
35
|
+
handler: httpHandler,
|
|
36
|
+
}), 'extension-manager: /extensions route')
|
|
26
37
|
})
|
|
27
38
|
const register = (name, description, parameters, execute) => ctx.tools.register(defineTool({ name, description, parameters, output, execute }))
|
|
28
39
|
|
|
@@ -35,7 +46,7 @@ export async function apply(ctx, config) {
|
|
|
35
46
|
register('extensions_add_skill', 'Add an existing root containing skill directories. Added sources are disabled until explicitly enabled.', {
|
|
36
47
|
id: string, directory: string,
|
|
37
48
|
}, (args, exec) => store.addSkill(args.id, args.directory, exec.signal))
|
|
38
|
-
register('extensions_add_mcp', 'Add a disabled MCP server. configuration is JSON
|
|
49
|
+
register('extensions_add_mcp', 'Add a disabled MCP server. configuration is JSON for stdio, Streamable HTTP, or legacy SSE transport. Do not supply secrets in arguments.', {
|
|
39
50
|
id: string, configuration: string,
|
|
40
51
|
}, (args, exec) => {
|
|
41
52
|
const input = JSON.parse(args.configuration)
|
package/src/mcp-sse.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Legacy HTTP+SSE MCP client used only for servers that predate Streamable HTTP. */
|
|
2
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
3
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
4
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
|
5
|
+
import { CallToolResultSchema, ListToolsResultSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
export const name = 'mcp-sse-client'
|
|
9
|
+
export const inject = ['tools']
|
|
10
|
+
|
|
11
|
+
const serverNamePattern = /^[A-Za-z0-9_-]{1,32}$/
|
|
12
|
+
const invalidNameCharacters = /[^A-Za-z0-9_-]/g
|
|
13
|
+
const maxPublicNameLength = 64
|
|
14
|
+
|
|
15
|
+
export const Config = Schema.object({
|
|
16
|
+
transport: Schema.const('sse'),
|
|
17
|
+
serverName: Schema.string().required().pattern(serverNamePattern),
|
|
18
|
+
url: Schema.string().required(),
|
|
19
|
+
toolCallTimeoutMs: Schema.number().default(60_000),
|
|
20
|
+
failOnStartupError: Schema.boolean().default(false),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export async function apply(ctx, config) {
|
|
24
|
+
const client = new Client({ name: `dsh-sse-${config.serverName}`, version: '1.0.0' }, { capabilities: {} })
|
|
25
|
+
const transport = new SSEClientTransport(new URL(config.url))
|
|
26
|
+
let disposed = false
|
|
27
|
+
let registrations = new Map()
|
|
28
|
+
let synchronization = Promise.resolve()
|
|
29
|
+
|
|
30
|
+
const clearTools = () => {
|
|
31
|
+
for (const dispose of registrations.values()) dispose()
|
|
32
|
+
registrations = new Map()
|
|
33
|
+
}
|
|
34
|
+
ctx.effect(() => () => {
|
|
35
|
+
disposed = true
|
|
36
|
+
clearTools()
|
|
37
|
+
void client.close().catch(() => {})
|
|
38
|
+
}, 'mcp-sse-client.connection')
|
|
39
|
+
|
|
40
|
+
const synchronizeTools = async () => {
|
|
41
|
+
const definitions = new Map()
|
|
42
|
+
let cursor
|
|
43
|
+
do {
|
|
44
|
+
const response = await client.request({
|
|
45
|
+
method: 'tools/list',
|
|
46
|
+
...(cursor === undefined ? {} : { params: { cursor } }),
|
|
47
|
+
}, ListToolsResultSchema)
|
|
48
|
+
for (const tool of response.tools) {
|
|
49
|
+
const publicName = publicToolName(config.serverName, tool.name)
|
|
50
|
+
if (definitions.has(publicName)) throw new Error(`SSE MCP server listed duplicate tool: ${tool.name}`)
|
|
51
|
+
definitions.set(publicName, createTool(client, publicName, tool, config.toolCallTimeoutMs))
|
|
52
|
+
}
|
|
53
|
+
cursor = response.nextCursor
|
|
54
|
+
} while (cursor)
|
|
55
|
+
if (disposed) return
|
|
56
|
+
clearTools()
|
|
57
|
+
const next = new Map()
|
|
58
|
+
try {
|
|
59
|
+
for (const [publicName, definition] of definitions) next.set(publicName, ctx.tools.register(definition))
|
|
60
|
+
registrations = next
|
|
61
|
+
} catch (error) {
|
|
62
|
+
for (const dispose of next.values()) dispose()
|
|
63
|
+
throw error
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
|
|
68
|
+
synchronization = synchronization.then(synchronizeTools).catch(error => {
|
|
69
|
+
ctx.logger.warn(`mcp-sse-client(${config.serverName}): tool refresh failed: ${messageOf(error)}`)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await client.connect(transport)
|
|
75
|
+
await synchronizeTools()
|
|
76
|
+
} catch (error) {
|
|
77
|
+
clearTools()
|
|
78
|
+
await client.close().catch(() => {})
|
|
79
|
+
if (config.failOnStartupError) throw new Error(`mcp-sse-client(${config.serverName}): initial connection failed: ${messageOf(error)}`, { cause: error })
|
|
80
|
+
ctx.logger.warn(`mcp-sse-client(${config.serverName}): initial connection failed: ${messageOf(error)}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createTool(client, publicName, tool, timeout) {
|
|
85
|
+
return {
|
|
86
|
+
name: publicName,
|
|
87
|
+
description: tool.description ?? '',
|
|
88
|
+
parameters: tool.inputSchema,
|
|
89
|
+
output: {
|
|
90
|
+
schema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: { content: { type: 'array', items: {} }, structuredContent: {} },
|
|
93
|
+
required: ['content'],
|
|
94
|
+
additionalProperties: true,
|
|
95
|
+
},
|
|
96
|
+
render: (_args, value) => [{ type: 'text', text: textContent(value.content) }],
|
|
97
|
+
},
|
|
98
|
+
async execute(args, exec) {
|
|
99
|
+
const result = await client.request({ method: 'tools/call', params: { name: tool.name, arguments: args } }, CallToolResultSchema, {
|
|
100
|
+
signal: exec.signal,
|
|
101
|
+
timeout,
|
|
102
|
+
})
|
|
103
|
+
if (result.isError) throw new Error(textContent(result.content) || `MCP tool ${tool.name} failed`)
|
|
104
|
+
return result
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function publicToolName(serverName, rawName) {
|
|
110
|
+
const joined = `mcp__${serverName}__${rawName}`
|
|
111
|
+
const normalized = joined.replace(invalidNameCharacters, '_')
|
|
112
|
+
if (normalized === joined && normalized.length <= maxPublicNameLength) return normalized
|
|
113
|
+
const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, 12)
|
|
114
|
+
return `${normalized.slice(0, maxPublicNameLength - 13)}_${hash}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function textContent(content) {
|
|
118
|
+
const text = Array.isArray(content) ? content.filter(item => item?.type === 'text').map(item => item.text).join('\n') : ''
|
|
119
|
+
return text || JSON.stringify(content ?? [])
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const messageOf = error => error instanceof Error ? error.message : String(error)
|
package/src/store.js
CHANGED
|
@@ -4,11 +4,13 @@ import { basename, isAbsolute, resolve, win32 } from 'node:path'
|
|
|
4
4
|
import { randomUUID, createHash } from 'node:crypto'
|
|
5
5
|
import { Config as McpConfig } from '@deepseek-ai/dsh-mcp-client'
|
|
6
6
|
import { Config as SkillConfig } from '@deepseek-ai/dsh-skill-filesystem'
|
|
7
|
+
import { Config as SseMcpConfig } from './mcp-sse.js'
|
|
7
8
|
import { parse, stringify } from 'yaml'
|
|
8
9
|
|
|
9
10
|
const moduleNames = {
|
|
10
11
|
skill: '@deepseek-ai/dsh-skill-filesystem',
|
|
11
12
|
mcp: '@deepseek-ai/dsh-mcp-client',
|
|
13
|
+
mcpSse: '@civilization/deepseek-harness-skill-mcp/mcp-sse',
|
|
12
14
|
}
|
|
13
15
|
const managerModule = new URL('./index.js', import.meta.url).href
|
|
14
16
|
const resolvedModules = Object.fromEntries(Object.entries(moduleNames).map(([kind, name]) => [kind, import.meta.resolve(name)]))
|
|
@@ -61,6 +63,9 @@ function parsePatch(value) {
|
|
|
61
63
|
} else if (isModule(row.name, 'mcp')) {
|
|
62
64
|
McpConfig(row.config)
|
|
63
65
|
if (row.config.serverName !== row.id) throw new Error('MCP namespace must match its id.')
|
|
66
|
+
} else if (isModule(row.name, 'mcpSse')) {
|
|
67
|
+
SseMcpConfig(row.config)
|
|
68
|
+
if (row.config.serverName !== row.id) throw new Error('MCP namespace must match its id.')
|
|
64
69
|
} else throw new Error('Unknown managed plugin module.')
|
|
65
70
|
managed.push(row)
|
|
66
71
|
}
|
|
@@ -183,7 +188,8 @@ export class ExtensionStore {
|
|
|
183
188
|
async addMcp(id, input, signal, expectedRevision) {
|
|
184
189
|
validateId(id)
|
|
185
190
|
const config = this.normalizeMcp(id, input)
|
|
186
|
-
|
|
191
|
+
const name = config.transport === 'sse' ? moduleNames.mcpSse : moduleNames.mcp
|
|
192
|
+
return this.add({ id, name, disabled: true, config }, signal, expectedRevision)
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
normalizeMcp(id, input) {
|
|
@@ -194,8 +200,10 @@ export class ExtensionStore {
|
|
|
194
200
|
command: typeof input.command === 'string' ? normalizeWindowsPath(input.command) : input.command,
|
|
195
201
|
cwd: typeof input.cwd === 'string' && input.cwd ? normalizeWindowsPath(input.cwd) : input.cwd,
|
|
196
202
|
} : input
|
|
197
|
-
const config =
|
|
198
|
-
|
|
203
|
+
const config = input.transport === 'sse'
|
|
204
|
+
? SseMcpConfig({ ...normalized, serverName: id, failOnStartupError: false })
|
|
205
|
+
: McpConfig({ ...normalized, serverName: id, failOnStartupError: false })
|
|
206
|
+
if (config.transport === 'streamable-http' || config.transport === 'sse') {
|
|
199
207
|
const url = new URL(config.url)
|
|
200
208
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('Use an HTTP(S) endpoint without credentials, query or fragment.')
|
|
201
209
|
} else if (!config.command.trim()) throw new Error('MCP command cannot be empty.')
|
|
@@ -235,6 +243,7 @@ export class ExtensionStore {
|
|
|
235
243
|
return this.update(rows => {
|
|
236
244
|
const row = rows.find(row => row.id === id && row.config.serverName)
|
|
237
245
|
if (!row) throw new Error('Unknown MCP server.')
|
|
246
|
+
row.name = config.transport === 'sse' ? moduleNames.mcpSse : moduleNames.mcp
|
|
238
247
|
row.config = config
|
|
239
248
|
}, signal, expectedRevision)
|
|
240
249
|
}
|
package/src/web.js
CHANGED
|
@@ -43,7 +43,7 @@ export async function describeExtensions(store, ctx) {
|
|
|
43
43
|
kind: row.config.serverName ? 'mcp' : 'skill',
|
|
44
44
|
enabled: !row.disabled,
|
|
45
45
|
location: row.config.transport === 'stdio' ? row.config.command
|
|
46
|
-
: row.config.transport
|
|
46
|
+
: ['streamable-http', 'sse'].includes(row.config.transport) ? new URL(row.config.url).origin
|
|
47
47
|
: row.config.customSkillDirs[0],
|
|
48
48
|
transport: row.config.transport ?? '',
|
|
49
49
|
configuration: row.config.serverName ? (row.config.transport === 'stdio'
|
|
@@ -61,7 +61,7 @@ export function createWebHandler(store, ctx) {
|
|
|
61
61
|
try {
|
|
62
62
|
if (!Object.hasOwn(schemas, endpoint)) throw new Error('Unknown operation.')
|
|
63
63
|
const args = schemas[endpoint](payload)
|
|
64
|
-
signal
|
|
64
|
+
signal?.throwIfAborted()
|
|
65
65
|
if (endpoint === 'add-skill') await store.addSkillPath(args.directory, signal, args.revision)
|
|
66
66
|
if (endpoint === 'add-mcp') {
|
|
67
67
|
const input = JSON.parse(args.configuration)
|
|
@@ -97,3 +97,68 @@ export function createWebHandler(store, ctx) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
/** HTTP server route handler that satisfies DSH WebServer and RPC envelope protocols. */
|
|
102
|
+
export function createWebHttpHandler(store, ctx) {
|
|
103
|
+
const handler = createWebHandler(store, ctx)
|
|
104
|
+
return async (req, res) => {
|
|
105
|
+
if (req.method === 'OPTIONS') {
|
|
106
|
+
res.writeHead(204, {
|
|
107
|
+
'access-control-allow-origin': '*',
|
|
108
|
+
'access-control-allow-methods': 'POST, OPTIONS',
|
|
109
|
+
'access-control-allow-headers': 'content-type, *',
|
|
110
|
+
})
|
|
111
|
+
res.end()
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (req.method !== 'POST') {
|
|
115
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
116
|
+
res.end(JSON.stringify({ ok: false, error: { message: 'Method Not Allowed' } }))
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
120
|
+
const endpoint = pathname.startsWith('/extensions/') ? pathname.slice('/extensions/'.length)
|
|
121
|
+
: pathname === '/extensions' ? 'list' : undefined
|
|
122
|
+
if (!endpoint) {
|
|
123
|
+
res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' })
|
|
124
|
+
res.end(JSON.stringify({ ok: false, error: { message: 'Not Found' } }))
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let raw = ''
|
|
129
|
+
try {
|
|
130
|
+
for await (const chunk of req) raw += chunk.toString('utf8')
|
|
131
|
+
} catch {
|
|
132
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
133
|
+
res.end(JSON.stringify({ ok: false, error: { message: 'Failed to read request body.' } }))
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let body = {}
|
|
138
|
+
if (raw.trim()) {
|
|
139
|
+
try { body = JSON.parse(raw) }
|
|
140
|
+
catch {
|
|
141
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
142
|
+
res.end(JSON.stringify({ ok: false, error: { message: 'Invalid JSON body.' } }))
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const isRpcEnvelope = body && body.type === 'client-request' && typeof body.rpcId === 'string'
|
|
148
|
+
const actualEndpoint = isRpcEnvelope ? (body.method || endpoint) : endpoint
|
|
149
|
+
const payload = isRpcEnvelope ? (body.payload ?? {}) : body
|
|
150
|
+
|
|
151
|
+
const result = await handler(actualEndpoint, payload, req.signal)
|
|
152
|
+
|
|
153
|
+
const responseBody = isRpcEnvelope
|
|
154
|
+
? { type: 'server-response', rpcId: body.rpcId, result }
|
|
155
|
+
: result
|
|
156
|
+
|
|
157
|
+
const data = JSON.stringify(responseBody)
|
|
158
|
+
res.writeHead(200, {
|
|
159
|
+
'content-type': 'application/json; charset=utf-8',
|
|
160
|
+
'content-length': Buffer.byteLength(data),
|
|
161
|
+
})
|
|
162
|
+
res.end(data)
|
|
163
|
+
}
|
|
164
|
+
}
|