dsh-plugin-capabilities 0.3.7 → 0.3.9
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 +4 -2
- package/lib/client.js +158 -32
- package/lib/client.js.map +2 -2
- package/lib/index.js +245 -38
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
- package/src/agents.test.ts +47 -1
- package/src/agents.ts +59 -8
- package/src/client/MarketTab.tsx +23 -7
- package/src/client/McpTab.tsx +128 -23
- package/src/client/index.ts +12 -8
- package/src/client/locales.ts +30 -8
- package/src/index.ts +2 -1
- package/src/mcp.test.ts +135 -2
- package/src/mcp.ts +156 -18
- package/src/profile.ts +6 -2
- package/src/routes.ts +116 -26
package/src/client/locales.ts
CHANGED
|
@@ -68,9 +68,20 @@ export const zh = {
|
|
|
68
68
|
confirmRemoveRoot: '确认移除该仓库?',
|
|
69
69
|
removeRootWarn: '将取消扫描该仓库并删除下载到 DSH_HOME 的副本(本地目录本身不受影响)。',
|
|
70
70
|
mcpTitle: 'MCP 服务器',
|
|
71
|
-
mcpIntro: '管理
|
|
71
|
+
mcpIntro: '管理 MCP 服务器行(@deepseek-ai/dsh-mcp-client)。服务器可放在 profile 层(仅本 profile)或全局层(DSH_HOME/cordis.patch.yml,对本机所有 profile 生效);新增、修改或删除后需要重启 dsh 才生效。',
|
|
72
72
|
addServer: '添加服务器',
|
|
73
73
|
editServer: '编辑服务器',
|
|
74
|
+
scopeLabel: '生效范围',
|
|
75
|
+
scopeGlobal: '全局',
|
|
76
|
+
scopeProfile: '当前 profile',
|
|
77
|
+
scopeHint: '全局:写入 DSH_HOME/cordis.patch.yml,对本机所有 profile 生效;当前 profile:只影响本 profile。',
|
|
78
|
+
shadowedByGlobal: '全局层存在同名 id,此行不生效',
|
|
79
|
+
globalLayerError: '全局配置(DSH_HOME/cordis.patch.yml)读取失败,全局服务器暂未显示:',
|
|
80
|
+
checkLabel: '检查',
|
|
81
|
+
checkRunning: '检查中…',
|
|
82
|
+
checkOk: '检查通过',
|
|
83
|
+
checkFail: '检查未通过',
|
|
84
|
+
copyToOther: '复制到另一层',
|
|
74
85
|
serverName: '服务器名(工具名前缀)',
|
|
75
86
|
transport: '传输方式',
|
|
76
87
|
transportStdio: 'stdio(本地命令)',
|
|
@@ -84,10 +95,10 @@ export const zh = {
|
|
|
84
95
|
enabled: '启用中',
|
|
85
96
|
toggle: '停用/启用',
|
|
86
97
|
confirmRemove: '确认移除该服务器?',
|
|
87
|
-
removeWarn: '
|
|
98
|
+
removeWarn: '将从配置中移除这一行,重启 dsh 后其工具不再出现。',
|
|
88
99
|
emptyMcp: '还没有配置 MCP 服务器',
|
|
89
100
|
importServers: '从其他 Agent 导入',
|
|
90
|
-
importIntro: '扫描 Claude Code(~/.claude.json
|
|
101
|
+
importIntro: '扫描 Claude Code(~/.claude.json)、Codex(~/.codex/config.toml)、Cursor(~/.cursor/mcp.json)与 Gemini CLI(~/.gemini/settings.json)的 MCP 服务器配置,勾选后导入到所选范围。',
|
|
91
102
|
importEmpty: '没有发现可导入的 MCP 服务器',
|
|
92
103
|
importSelectAll: '全选',
|
|
93
104
|
importExisting: '已存在',
|
|
@@ -113,7 +124,7 @@ export const zh = {
|
|
|
113
124
|
marketSkills: '技能市场',
|
|
114
125
|
marketMcp: 'MCP 市场',
|
|
115
126
|
marketSkillsIntro: '安装技能仓库(GitHub 下载解包,实时进入扫描目录);已安装的仓库可在「技能」页管理。',
|
|
116
|
-
marketMcpIntro: '添加 MCP
|
|
127
|
+
marketMcpIntro: '添加 MCP 服务器行(上方可选写入当前 profile 还是全局层,添加后需重启生效);需要密钥的服务在安装后于列表中补填环境变量。',
|
|
117
128
|
marketEmpty: '市场列表为空或不可用',
|
|
118
129
|
marketOffline: '在线索引不可达,当前展示包内快照(可能与最新列表有差异)。',
|
|
119
130
|
marketSkillCount: '{n} 个技能',
|
|
@@ -201,9 +212,20 @@ export const en = {
|
|
|
201
212
|
confirmRemoveRoot: 'Remove this repository?',
|
|
202
213
|
removeRootWarn: 'Stops scanning the repository and deletes the copy downloaded into DSH_HOME (a local folder itself is untouched).',
|
|
203
214
|
mcpTitle: 'MCP servers',
|
|
204
|
-
mcpIntro: 'Manage
|
|
215
|
+
mcpIntro: 'Manage MCP server rows (@deepseek-ai/dsh-mcp-client). A server lives either in this profile or in the global layer (DSH_HOME/cordis.patch.yml, applied to every profile on this machine); additions, edits, and removals take effect after a dsh restart.',
|
|
205
216
|
addServer: 'Add server',
|
|
206
217
|
editServer: 'Edit server',
|
|
218
|
+
scopeLabel: 'Scope',
|
|
219
|
+
scopeGlobal: 'Global',
|
|
220
|
+
scopeProfile: 'This profile',
|
|
221
|
+
scopeHint: 'Global writes DSH_HOME/cordis.patch.yml and applies to every profile on this machine; this profile writes the current profile only.',
|
|
222
|
+
shadowedByGlobal: 'a global row with the same id wins — this row has no effect',
|
|
223
|
+
globalLayerError: 'Failed to read the global layer (DSH_HOME/cordis.patch.yml); global servers are hidden:',
|
|
224
|
+
checkLabel: 'Check',
|
|
225
|
+
checkRunning: 'Checking…',
|
|
226
|
+
checkOk: 'OK',
|
|
227
|
+
checkFail: 'Failed',
|
|
228
|
+
copyToOther: 'Copy to other layer',
|
|
207
229
|
serverName: 'Server name (tool name prefix)',
|
|
208
230
|
transport: 'Transport',
|
|
209
231
|
transportStdio: 'stdio (local command)',
|
|
@@ -217,10 +239,10 @@ export const en = {
|
|
|
217
239
|
enabled: 'Enabled',
|
|
218
240
|
toggle: 'Enable/disable',
|
|
219
241
|
confirmRemove: 'Remove this server?',
|
|
220
|
-
removeWarn: 'Removes the row from
|
|
242
|
+
removeWarn: 'Removes the row from its configuration; its tools disappear after the next dsh restart.',
|
|
221
243
|
emptyMcp: 'No MCP servers configured yet',
|
|
222
244
|
importServers: 'Import from other agents',
|
|
223
|
-
importIntro: 'Scans Claude Code (~/.claude.json)
|
|
245
|
+
importIntro: 'Scans the MCP server configs of Claude Code (~/.claude.json), Codex (~/.codex/config.toml), Cursor (~/.cursor/mcp.json), and Gemini CLI (~/.gemini/settings.json); selected entries land in the scope picked below.',
|
|
224
246
|
importEmpty: 'No importable MCP servers found',
|
|
225
247
|
importSelectAll: 'Select all',
|
|
226
248
|
importExisting: 'already here',
|
|
@@ -246,7 +268,7 @@ export const en = {
|
|
|
246
268
|
marketSkills: 'Skills market',
|
|
247
269
|
marketMcp: 'MCP market',
|
|
248
270
|
marketSkillsIntro: 'Installs skill repositories (GitHub download, live catalog); manage installed ones on the Skills tab.',
|
|
249
|
-
marketMcpIntro: 'Adds MCP server rows (
|
|
271
|
+
marketMcpIntro: 'Adds MCP server rows (pick the target layer — this profile or global — above; restart to apply). Servers needing keys get their environment filled in on the row after install.',
|
|
250
272
|
marketEmpty: 'Market list is empty or unavailable',
|
|
251
273
|
marketOffline: 'Online index unreachable — showing the bundled snapshot, which may lag behind.',
|
|
252
274
|
marketSkillCount: '{n} skills',
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs'
|
|
|
11
11
|
import { dirname, join } from 'node:path'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
import { agentSkillRoots } from './agents.ts'
|
|
14
|
-
import { argvProfile, profileDir } from './profile.ts'
|
|
14
|
+
import { argvProfile, dshHomeDir, profileDir } from './profile.ts'
|
|
15
15
|
import { mountCapabilitiesRoutes } from './routes.ts'
|
|
16
16
|
import { loadState } from './state.ts'
|
|
17
17
|
import type { CapabilitiesHost } from './types.ts'
|
|
@@ -102,6 +102,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
102
102
|
ctx.effect(
|
|
103
103
|
() => mountCapabilitiesRoutes(hostCtx as unknown as CapabilitiesHost, {
|
|
104
104
|
profileDirPath: profileDir(profile),
|
|
105
|
+
dshHomePath: dshHomeDir(),
|
|
105
106
|
remountProvider,
|
|
106
107
|
}),
|
|
107
108
|
'dsh-plugin-capabilities: http routes',
|
package/src/mcp.test.ts
CHANGED
|
@@ -2,11 +2,15 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
-
import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
|
|
5
|
+
import { checkMcpRow, listMcp, listMcpScoped, mcpRowToInput, mcpScopeDir, removeMcp, resolveCommandOnPath, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
|
|
6
6
|
|
|
7
7
|
const root = mkdtempSync(join(tmpdir(), 'dsh-caps-mcp-'))
|
|
8
8
|
const profile = join(root, 'profiles', 'web')
|
|
9
|
-
|
|
9
|
+
const home = join(root, 'home')
|
|
10
|
+
afterAll(() => {
|
|
11
|
+
rmSync(root, { recursive: true, force: true })
|
|
12
|
+
rmSync('dsh-caps-path-bin', { recursive: true, force: true })
|
|
13
|
+
})
|
|
10
14
|
|
|
11
15
|
const patch = () => join(profile, 'cordis.patch.yml')
|
|
12
16
|
|
|
@@ -149,3 +153,132 @@ describe('corrupt patch file', () => {
|
|
|
149
153
|
expect(readFileSync(join(brokenDir, 'cordis.patch.yml'), 'utf8')).toBe(before)
|
|
150
154
|
})
|
|
151
155
|
})
|
|
156
|
+
|
|
157
|
+
describe('global patch layer (GitHub issue #2)', () => {
|
|
158
|
+
const cleanHome = (): void => rmSync(join(home, 'cordis.patch.yml'), { force: true })
|
|
159
|
+
|
|
160
|
+
it('merges both layers, global first, and flags shadowed profile rows', () => {
|
|
161
|
+
cleanHome()
|
|
162
|
+
mkdirSync(home, { recursive: true })
|
|
163
|
+
writeFileSync(join(home, 'cordis.patch.yml'), [
|
|
164
|
+
'- insert:',
|
|
165
|
+
' - id: mcp-shared-tools',
|
|
166
|
+
" name: '@deepseek-ai/dsh-mcp-client'",
|
|
167
|
+
' config:',
|
|
168
|
+
' serverName: shared-tools',
|
|
169
|
+
' transport: stdio',
|
|
170
|
+
' command: node',
|
|
171
|
+
' args:',
|
|
172
|
+
' - global.js',
|
|
173
|
+
'',
|
|
174
|
+
].join('\n'), 'utf8')
|
|
175
|
+
|
|
176
|
+
upsertMcp(profile, { ...stdio, serverName: 'github' })
|
|
177
|
+
// Same id as the global row: the home layer composes after the profile
|
|
178
|
+
// layer, so this profile row never takes effect.
|
|
179
|
+
upsertMcp(profile, { ...stdio, serverName: 'shared', id: 'mcp-shared-tools' })
|
|
180
|
+
|
|
181
|
+
const { servers, globalError } = listMcpScoped(profile, home)
|
|
182
|
+
expect(globalError).toBeUndefined()
|
|
183
|
+
expect(servers.map(row => `${row.scope}/${row.id}`)).toEqual(['global/mcp-shared-tools', 'profile/mcp-github', 'profile/mcp-shared-tools'])
|
|
184
|
+
expect(servers[0]).toMatchObject({ serverName: 'shared-tools', args: ['global.js'] })
|
|
185
|
+
expect(servers[2].shadowed).toBe(true)
|
|
186
|
+
expect(servers[1].shadowed).toBeUndefined()
|
|
187
|
+
|
|
188
|
+
cleanHome()
|
|
189
|
+
expect(listMcpScoped(profile, home).servers.every(row => row.scope === 'profile')).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('degrades a broken global file to globalError and still lists profile rows', () => {
|
|
193
|
+
mkdirSync(home, { recursive: true })
|
|
194
|
+
writeFileSync(join(home, 'cordis.patch.yml'), 'foo: 1\n bar: 2\n', 'utf8')
|
|
195
|
+
const { servers, globalError } = listMcpScoped(profile, home)
|
|
196
|
+
expect(globalError).toMatch(/cordis\.patch\.yml/)
|
|
197
|
+
expect(globalError).toMatch(/line 1/)
|
|
198
|
+
expect(servers.length).toBeGreaterThan(0)
|
|
199
|
+
expect(servers.every(row => row.scope === 'profile')).toBe(true)
|
|
200
|
+
cleanHome()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('writes, toggles, and removes through the global scope dir', () => {
|
|
204
|
+
cleanHome()
|
|
205
|
+
const globalDir = mcpScopeDir('global', profile, home)
|
|
206
|
+
expect(globalDir).toBe(home)
|
|
207
|
+
|
|
208
|
+
const id = upsertMcp(globalDir, { ...stdio, serverName: 'shared-tools' })
|
|
209
|
+
expect(id).toBe('mcp-shared-tools')
|
|
210
|
+
// The global layer file (not the profile's) received the row.
|
|
211
|
+
expect(readFileSync(join(home, 'cordis.patch.yml'), 'utf8')).toContain('shared-tools')
|
|
212
|
+
|
|
213
|
+
expect(setMcpDisabled(globalDir, id, true)).toBe(true)
|
|
214
|
+
expect(listMcp(home)[0].disabled).toBe(true)
|
|
215
|
+
expect(removeMcp(globalDir, id)).toBe(true)
|
|
216
|
+
expect(listMcp(home)).toHaveLength(0)
|
|
217
|
+
expect(listMcp(profile).some(row => row.serverName === 'shared-tools')).toBe(false)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('mcpRowToInput', () => {
|
|
222
|
+
it('rebuilds a create request carrying identity fields but not the id', () => {
|
|
223
|
+
const rows = listMcp(profile)
|
|
224
|
+
const source = rows[0]
|
|
225
|
+
const input = mcpRowToInput(source)
|
|
226
|
+
expect(input.id).toBe('')
|
|
227
|
+
expect(input.serverName).toBe(source.serverName)
|
|
228
|
+
expect(input.transport).toBe(source.transport)
|
|
229
|
+
expect(input.command).toBe(source.command)
|
|
230
|
+
expect(input.args).toEqual(source.args)
|
|
231
|
+
expect(input.env).toEqual(source.env)
|
|
232
|
+
expect(validateMcpInput(input)).toBeNull()
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('command resolution + connectivity check', () => {
|
|
237
|
+
it('resolves bare commands across PATH entries, honoring Windows extensions and quotes', () => {
|
|
238
|
+
const bin = join(root, 'bin')
|
|
239
|
+
mkdirSync(bin, { recursive: true })
|
|
240
|
+
writeFileSync(join(bin, 'tool.cmd'), '@echo off\r\n')
|
|
241
|
+
writeFileSync(join(bin, 'plain'), '#!/bin/sh\n')
|
|
242
|
+
|
|
243
|
+
expect(resolveCommandOnPath('tool', `"${bin}"`, 'win32')).toBe(true)
|
|
244
|
+
expect(resolveCommandOnPath('tool', bin, 'win32')).toBe(true)
|
|
245
|
+
expect(resolveCommandOnPath('tool', bin, 'linux')).toBe(false)
|
|
246
|
+
expect(resolveCommandOnPath('plain', bin, 'win32')).toBe(true)
|
|
247
|
+
expect(resolveCommandOnPath('missing-thing', bin, 'win32')).toBe(false)
|
|
248
|
+
expect(resolveCommandOnPath('missing-thing', `${bin};`, 'win32')).toBe(false)
|
|
249
|
+
expect(resolveCommandOnPath(join(bin, 'tool.cmd'), '', 'win32')).toBe(true)
|
|
250
|
+
expect(resolveCommandOnPath(join(bin, 'nope'), '', 'win32')).toBe(false)
|
|
251
|
+
|
|
252
|
+
// The POSIX branch splits on ':'; a Windows temp path carries a drive
|
|
253
|
+
// letter, so exercise it through a colon-free relative entry instead.
|
|
254
|
+
mkdirSync('dsh-caps-path-bin', { recursive: true })
|
|
255
|
+
writeFileSync(join('dsh-caps-path-bin', 'plain'), '#!/bin/sh\n')
|
|
256
|
+
expect(resolveCommandOnPath('plain', 'dsh-caps-path-bin:/elsewhere', 'linux')).toBe(true)
|
|
257
|
+
expect(resolveCommandOnPath('tool', 'dsh-caps-path-bin:/elsewhere', 'linux')).toBe(false)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('checks stdio rows against PATH without spawning anything', async () => {
|
|
261
|
+
const base = { id: 'mcp-x', serverName: 'x', disabled: false, scope: 'profile' as const }
|
|
262
|
+
const missing = await checkMcpRow({ ...base, transport: 'stdio', command: 'definitely-not-a-real-cmd-xyz' }, { pathEnv: '' })
|
|
263
|
+
expect(missing.ok).toBe(false)
|
|
264
|
+
expect(missing.detail).toContain('not found on PATH')
|
|
265
|
+
|
|
266
|
+
const bin = join(root, 'bin')
|
|
267
|
+
const found = await checkMcpRow({ ...base, transport: 'stdio', command: 'tool' }, { pathEnv: bin, platform: 'win32' })
|
|
268
|
+
expect(found.ok).toBe(true)
|
|
269
|
+
|
|
270
|
+
const empty = await checkMcpRow({ ...base, transport: 'stdio' }, {})
|
|
271
|
+
expect(empty.ok).toBe(false)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('checks http rows with a bounded GET; refusal reads as unreachable', async () => {
|
|
275
|
+
const base = { id: 'mcp-y', serverName: 'y', disabled: false, scope: 'profile' as const }
|
|
276
|
+
// Port 1 has no listener: the connect fails fast (ECONNREFUSED), which is
|
|
277
|
+
// exactly the signal the check exists to surface.
|
|
278
|
+
const refused = await checkMcpRow({ ...base, transport: 'streamable-http', url: 'http://127.0.0.1:1/mcp' }, { timeoutMs: 1500 })
|
|
279
|
+
expect(refused.ok).toBe(false)
|
|
280
|
+
|
|
281
|
+
const broken = await checkMcpRow({ ...base, transport: 'streamable-http' }, {})
|
|
282
|
+
expect(broken.ok).toBe(false)
|
|
283
|
+
}, 10_000)
|
|
284
|
+
})
|
package/src/mcp.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* list. Managed rows therefore always sit inside one insert entry, and any
|
|
11
11
|
* legacy bare rows (written before this contract was understood) are
|
|
12
12
|
* absorbed into it on the next write.
|
|
13
|
+
*
|
|
14
|
+
* Rows live in one of two patch layers: the profile's own
|
|
15
|
+
* `cordis.patch.yml`, or the machine-wide `$DSH_HOME/cordis.patch.yml`
|
|
16
|
+
* (dsh composes it over every profile, after the profile layer — a home row
|
|
17
|
+
* with the same id wins). The primitives below address one layer via the
|
|
18
|
+
* directory holding its `cordis.patch.yml`; `listMcpScoped` merges both.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
@@ -24,6 +30,9 @@ export const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/
|
|
|
24
30
|
/** Transport choices the client supports. */
|
|
25
31
|
export type McpTransport = 'stdio' | 'streamable-http'
|
|
26
32
|
|
|
33
|
+
/** Which patch layer a row lives in. */
|
|
34
|
+
export type McpScope = 'global' | 'profile'
|
|
35
|
+
|
|
27
36
|
/** One managed row, as shown to the browser. */
|
|
28
37
|
export interface McpRow {
|
|
29
38
|
id: string
|
|
@@ -38,6 +47,20 @@ export interface McpRow {
|
|
|
38
47
|
headers?: Record<string, string>
|
|
39
48
|
}
|
|
40
49
|
|
|
50
|
+
/** One row tagged with its layer. */
|
|
51
|
+
export interface McpRowView extends McpRow {
|
|
52
|
+
scope: McpScope
|
|
53
|
+
/** Profile rows only: a global row with the same id composes after this one and wins. */
|
|
54
|
+
shadowed?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Merged listing across both patch layers. */
|
|
58
|
+
export interface McpListResult {
|
|
59
|
+
servers: McpRowView[]
|
|
60
|
+
/** The home layer exists but could not be read; its rows are not shown. */
|
|
61
|
+
globalError?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
/** Write request for one server row (id empty = create). */
|
|
42
65
|
export type McpInput = Omit<McpRow, 'disabled'> & { disabled?: boolean }
|
|
43
66
|
|
|
@@ -58,9 +81,9 @@ function assertPatchParses(path: string, doc: Document): void {
|
|
|
58
81
|
)
|
|
59
82
|
}
|
|
60
83
|
|
|
61
|
-
/** Load
|
|
62
|
-
function loadPatch(
|
|
63
|
-
const path = join(
|
|
84
|
+
/** Load one layer's patch (`<dirPath>/cordis.patch.yml`) as a YAML document; `[]` for a missing file. */
|
|
85
|
+
function loadPatch(dirPath: string): Document {
|
|
86
|
+
const path = join(dirPath, 'cordis.patch.yml')
|
|
64
87
|
const text = existsSync(path) ? readFileSync(path, 'utf8') : '[]'
|
|
65
88
|
const doc = parseDocument(text)
|
|
66
89
|
assertPatchParses(path, doc)
|
|
@@ -71,9 +94,9 @@ function loadPatch(profileDirPath: string): Document {
|
|
|
71
94
|
return doc
|
|
72
95
|
}
|
|
73
96
|
|
|
74
|
-
function savePatch(
|
|
75
|
-
mkdirSync(
|
|
76
|
-
writeFileSync(join(
|
|
97
|
+
function savePatch(dirPath: string, doc: Document): void {
|
|
98
|
+
mkdirSync(dirPath, { recursive: true })
|
|
99
|
+
writeFileSync(join(dirPath, 'cordis.patch.yml'), String(doc), 'utf8')
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
/** Wrap a plain value into a YAML node (yaml v2 exposes no standalone createNode). */
|
|
@@ -183,12 +206,49 @@ function takenIds(doc: Document): Set<string> {
|
|
|
183
206
|
return taken
|
|
184
207
|
}
|
|
185
208
|
|
|
186
|
-
/** Read every mcp-client row in
|
|
187
|
-
export function listMcp(
|
|
188
|
-
const doc = loadPatch(
|
|
209
|
+
/** Read every mcp-client row in one patch layer (`<dirPath>/cordis.patch.yml`). */
|
|
210
|
+
export function listMcp(dirPath: string): McpRow[] {
|
|
211
|
+
const doc = loadPatch(dirPath)
|
|
189
212
|
return mcpRowItems(doc).map(({ node }) => rowToMcp(doc, node))
|
|
190
213
|
}
|
|
191
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Merge both patch layers for the browser: global rows first (dsh composes
|
|
217
|
+
* them after the profile layer, so they win), profile rows tagged `shadowed`
|
|
218
|
+
* when a global row claims the same id. A broken home layer must not take
|
|
219
|
+
* the whole page down — its read failure degrades to `globalError` and the
|
|
220
|
+
* profile layer still lists (the assertPatchParses message names the file
|
|
221
|
+
* and the parser location, which is what the banner shows).
|
|
222
|
+
*/
|
|
223
|
+
export function listMcpScoped(profileDirPath: string, dshHomePath: string): McpListResult {
|
|
224
|
+
let globalRows: McpRow[] = []
|
|
225
|
+
let globalError: string | undefined
|
|
226
|
+
if (existsSync(join(dshHomePath, 'cordis.patch.yml'))) {
|
|
227
|
+
try {
|
|
228
|
+
globalRows = listMcp(dshHomePath)
|
|
229
|
+
} catch (error) {
|
|
230
|
+
globalError = error instanceof Error ? error.message : String(error)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const globalIds = new Set(globalRows.map(row => row.id).filter(id => id !== ''))
|
|
234
|
+
return {
|
|
235
|
+
servers: [
|
|
236
|
+
...globalRows.map((row): McpRowView => ({ ...row, scope: 'global' })),
|
|
237
|
+
...listMcp(profileDirPath).map((row): McpRowView => ({
|
|
238
|
+
...row,
|
|
239
|
+
scope: 'profile',
|
|
240
|
+
...(globalIds.has(row.id) ? { shadowed: true } : {}),
|
|
241
|
+
})),
|
|
242
|
+
],
|
|
243
|
+
...(globalError !== undefined ? { globalError } : {}),
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Resolve a write target: the layer's directory holding its cordis.patch.yml. */
|
|
248
|
+
export function mcpScopeDir(scope: McpScope, profileDirPath: string, dshHomePath: string): string {
|
|
249
|
+
return scope === 'global' ? dshHomePath : profileDirPath
|
|
250
|
+
}
|
|
251
|
+
|
|
192
252
|
/** Validate one write request; returns the rejection reason or null. */
|
|
193
253
|
export function validateMcpInput(input: McpInput): string | null {
|
|
194
254
|
if (!SERVER_NAME_RE.test(input.serverName)) return 'serverName must be 1-32 chars of A-Z a-z 0-9 _ -'
|
|
@@ -204,9 +264,9 @@ export function validateMcpInput(input: McpInput): string | null {
|
|
|
204
264
|
}
|
|
205
265
|
|
|
206
266
|
/** Add or replace one server row. Returns the (possibly deduplicated) id. */
|
|
207
|
-
export function upsertMcp(
|
|
267
|
+
export function upsertMcp(dirPath: string, input: McpInput): string {
|
|
208
268
|
const inputId = input.id ?? ''
|
|
209
|
-
const doc = loadPatch(
|
|
269
|
+
const doc = loadPatch(dirPath)
|
|
210
270
|
const list = managedInsert(doc)
|
|
211
271
|
|
|
212
272
|
const existing = inputId !== ''
|
|
@@ -248,25 +308,25 @@ export function upsertMcp(profileDirPath: string, input: McpInput): string {
|
|
|
248
308
|
rowSeq(doc).items.splice(rowSeq(doc).items.indexOf(existing.node), 1, node)
|
|
249
309
|
}
|
|
250
310
|
|
|
251
|
-
savePatch(
|
|
311
|
+
savePatch(dirPath, doc)
|
|
252
312
|
return id
|
|
253
313
|
}
|
|
254
314
|
|
|
255
315
|
/** Flip one row's disabled flag (absent = enabled). Returns false when missing. */
|
|
256
|
-
export function setMcpDisabled(
|
|
257
|
-
const doc = loadPatch(
|
|
316
|
+
export function setMcpDisabled(dirPath: string, id: string, disabled: boolean): boolean {
|
|
317
|
+
const doc = loadPatch(dirPath)
|
|
258
318
|
managedInsert(doc)
|
|
259
319
|
const hit = mcpRowItems(doc).find(({ node }) => String(node.get('id') ?? '') === id)
|
|
260
320
|
if (hit === undefined) return false
|
|
261
321
|
if (disabled) hit.node.set('disabled', true)
|
|
262
322
|
else hit.node.delete('disabled')
|
|
263
|
-
savePatch(
|
|
323
|
+
savePatch(dirPath, doc)
|
|
264
324
|
return true
|
|
265
325
|
}
|
|
266
326
|
|
|
267
327
|
/** Remove one server row. Returns false when missing. */
|
|
268
|
-
export function removeMcp(
|
|
269
|
-
const doc = loadPatch(
|
|
328
|
+
export function removeMcp(dirPath: string, id: string): boolean {
|
|
329
|
+
const doc = loadPatch(dirPath)
|
|
270
330
|
managedInsert(doc)
|
|
271
331
|
const hit = mcpRowItems(doc).find(({ node }) => String(node.get('id') ?? '') === id)
|
|
272
332
|
if (hit === undefined || hit.list === undefined) return false
|
|
@@ -280,6 +340,84 @@ export function removeMcp(profileDirPath: string, id: string): boolean {
|
|
|
280
340
|
seq.items.splice(seq.items.indexOf(owner), 1)
|
|
281
341
|
}
|
|
282
342
|
|
|
283
|
-
savePatch(
|
|
343
|
+
savePatch(dirPath, doc)
|
|
284
344
|
return true
|
|
285
345
|
}
|
|
346
|
+
|
|
347
|
+
/** Rebuild a create request from an existing row (id emptied; identity fields
|
|
348
|
+
* carried over) — used to copy a row into the other patch layer. */
|
|
349
|
+
export function mcpRowToInput(row: McpRow): McpInput {
|
|
350
|
+
return {
|
|
351
|
+
id: '',
|
|
352
|
+
serverName: row.serverName,
|
|
353
|
+
transport: row.transport,
|
|
354
|
+
disabled: row.disabled,
|
|
355
|
+
...(row.transport === 'stdio'
|
|
356
|
+
? {
|
|
357
|
+
command: row.command,
|
|
358
|
+
...(row.args !== undefined ? { args: row.args } : {}),
|
|
359
|
+
...(row.env !== undefined ? { env: row.env } : {}),
|
|
360
|
+
...(row.cwd !== undefined ? { cwd: row.cwd } : {}),
|
|
361
|
+
}
|
|
362
|
+
: {
|
|
363
|
+
url: row.url,
|
|
364
|
+
...(row.headers !== undefined ? { headers: row.headers } : {}),
|
|
365
|
+
}),
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Whether a stdio command would resolve at spawn time: a bare name is looked
|
|
371
|
+
* up in the PATH entries (with the Windows executable extensions), anything
|
|
372
|
+
* with a path separator must exist as given. Pure filesystem reads — no
|
|
373
|
+
* process is spawned, so no console-window or side-effect concerns.
|
|
374
|
+
*/
|
|
375
|
+
export function resolveCommandOnPath(command: string, pathEnv: string, platform: string = process.platform): boolean {
|
|
376
|
+
if (command.includes('/') || command.includes('\\')) return existsSync(command)
|
|
377
|
+
const exts = platform === 'win32' ? ['', '.com', '.exe', '.bat', '.cmd'] : ['']
|
|
378
|
+
const separator = platform === 'win32' ? ';' : ':'
|
|
379
|
+
for (const rawDir of pathEnv.split(separator)) {
|
|
380
|
+
const dir = rawDir.trim().replace(/^"|"$/g, '')
|
|
381
|
+
if (dir === '') continue
|
|
382
|
+
for (const ext of exts) {
|
|
383
|
+
if (existsSync(join(dir, command + ext))) return true
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return false
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Outcome of a connectivity check, as the browser renders it. */
|
|
390
|
+
export interface McpCheckResult {
|
|
391
|
+
ok: boolean
|
|
392
|
+
/** Short technical detail for the hint line ("HTTP 200", "not found on PATH"). */
|
|
393
|
+
detail?: string
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Probe one server row without starting it: stdio rows are validated against
|
|
398
|
+
* the PATH (a spawn would leave console windows and side effects behind),
|
|
399
|
+
* streamable-http rows get a short GET — any HTTP status proves reachability,
|
|
400
|
+
* since MCP endpoints legitimately answer 405 to plain GETs.
|
|
401
|
+
*/
|
|
402
|
+
export async function checkMcpRow(row: McpRow, options: { timeoutMs?: number; pathEnv?: string; platform?: string } = {}): Promise<McpCheckResult> {
|
|
403
|
+
const pathEnv = options.pathEnv ?? process.env.PATH ?? ''
|
|
404
|
+
if (row.transport === 'stdio') {
|
|
405
|
+
const command = row.command ?? ''
|
|
406
|
+
if (command === '') return { ok: false, detail: 'row has no command' }
|
|
407
|
+
return resolveCommandOnPath(command, pathEnv, options.platform)
|
|
408
|
+
? { ok: true, detail: command }
|
|
409
|
+
: { ok: false, detail: `${command} not found on PATH` }
|
|
410
|
+
}
|
|
411
|
+
if (row.url === undefined || !/^https?:\/\//.test(row.url)) return { ok: false, detail: 'row has no http url' }
|
|
412
|
+
try {
|
|
413
|
+
const response = await fetch(row.url, {
|
|
414
|
+
headers: { accept: 'application/json, text/event-stream' },
|
|
415
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 5000),
|
|
416
|
+
})
|
|
417
|
+
return { ok: true, detail: `HTTP ${response.status}` }
|
|
418
|
+
} catch (error) {
|
|
419
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
420
|
+
const cause = (error as { cause?: { code?: unknown } }).cause?.code
|
|
421
|
+
return { ok: false, detail: cause !== undefined ? String(cause) : message }
|
|
422
|
+
}
|
|
423
|
+
}
|
package/src/profile.ts
CHANGED
|
@@ -10,8 +10,12 @@ export function argvProfile(argv: readonly string[] = process.argv): string | un
|
|
|
10
10
|
return undefined
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** The dsh home directory itself: `$DSH_HOME`, default `~/.dsh`. */
|
|
14
|
+
export function dshHomeDir(dshHome: string | undefined = process.env.DSH_HOME): string {
|
|
15
|
+
return dshHome ?? join(homedir(), '.dsh')
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
/** Directory of a profile under DSH_HOME (default `~/.dsh`). */
|
|
14
19
|
export function profileDir(profile: string, dshHome: string | undefined = process.env.DSH_HOME): string {
|
|
15
|
-
|
|
16
|
-
return join(home, 'profiles', profile)
|
|
20
|
+
return join(dshHomeDir(dshHome), 'profiles', profile)
|
|
17
21
|
}
|