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.
@@ -68,9 +68,20 @@ export const zh = {
68
68
  confirmRemoveRoot: '确认移除该仓库?',
69
69
  removeRootWarn: '将取消扫描该仓库并删除下载到 DSH_HOME 的副本(本地目录本身不受影响)。',
70
70
  mcpTitle: 'MCP 服务器',
71
- mcpIntro: '管理 profile 中的 MCP 服务器行(@deepseek-ai/dsh-mcp-client)。新增、修改或删除后需要重启 dsh 才生效。',
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: '将从 profile 配置中移除这一行,重启 dsh 后其工具不再出现。',
98
+ removeWarn: '将从配置中移除这一行,重启 dsh 后其工具不再出现。',
88
99
  emptyMcp: '还没有配置 MCP 服务器',
89
100
  importServers: '从其他 Agent 导入',
90
- importIntro: '扫描 Claude Code(~/.claude.json)与 Codex(~/.codex/config.toml)的 MCP 服务器配置,勾选后导入为本 profile 的服务器行。',
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 服务器行(与「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 the MCP server rows (@deepseek-ai/dsh-mcp-client) in this profile. Additions, edits, and removals take effect after a dsh restart.',
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 the profile configuration; its tools disappear after the next dsh restart.',
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) and Codex (~/.codex/config.toml) MCP server configs; selected entries become server rows in this profile.',
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 (same storage as the MCP tab; restart to apply). Servers needing keys get their environment filled in on the row after install.',
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
- afterAll(() => rmSync(root, { recursive: true, force: true }))
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 the profile patch as a YAML document; `[]` for a missing file. */
62
- function loadPatch(profileDirPath: string): Document {
63
- const path = join(profileDirPath, 'cordis.patch.yml')
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(profileDirPath: string, doc: Document): void {
75
- mkdirSync(profileDirPath, { recursive: true })
76
- writeFileSync(join(profileDirPath, 'cordis.patch.yml'), String(doc), 'utf8')
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 the profile layer. */
187
- export function listMcp(profileDirPath: string): McpRow[] {
188
- const doc = loadPatch(profileDirPath)
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(profileDirPath: string, input: McpInput): string {
267
+ export function upsertMcp(dirPath: string, input: McpInput): string {
208
268
  const inputId = input.id ?? ''
209
- const doc = loadPatch(profileDirPath)
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(profileDirPath, doc)
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(profileDirPath: string, id: string, disabled: boolean): boolean {
257
- const doc = loadPatch(profileDirPath)
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(profileDirPath, doc)
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(profileDirPath: string, id: string): boolean {
269
- const doc = loadPatch(profileDirPath)
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(profileDirPath, doc)
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
- const home = dshHome ?? join(homedir(), '.dsh')
16
- return join(home, 'profiles', profile)
20
+ return join(dshHomeDir(dshHome), 'profiles', profile)
17
21
  }