dsh-plugin-capabilities 0.3.7 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-capabilities",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Manage skills and MCP servers from the Web UI Settings. 在设置页管理 dsh 的技能与 MCP 服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -114,7 +114,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
114
114
  setOutcome({ ok: true, text: t('rootRemoved') })
115
115
  refreshSkills()
116
116
  } else {
117
- await mcp.remove(target.id)
117
+ await mcp.remove(target.id, 'profile')
118
118
  setOutcome({ ok: true, text: t('restartNeeded') })
119
119
  refreshMcp()
120
120
  }
@@ -1,6 +1,7 @@
1
- /** Settings → Plugins “MCP” tab: manage the profile's mcp-client rows.
2
- * Mutations rewrite the profile patch and need a dsh restart — the banner
3
- * hands the restart to the desktop shell when one is present. */
1
+ /** Settings → Plugins “MCP” tab: manage the mcp-client rows in both patch
2
+ * layers (profile + global). Mutations rewrite the target patch and need a
3
+ * dsh restart — the banner hands the restart to the desktop shell when one
4
+ * is present. */
4
5
 
5
6
  import { useEffect, useState } from 'react'
6
7
  import type { ReactElement } from 'react'
@@ -8,11 +9,17 @@ import { Button, IconApiOutline14, IconRefreshOutline14, Modal, StateDot } from
8
9
  import { CSS } from './css.ts'
9
10
  import type { ImportedServerView, Translate } from './index.ts'
10
11
 
12
+ /** Which patch layer a row lives in. */
13
+ export type McpScope = 'global' | 'profile'
14
+
11
15
  export interface McpRow {
12
16
  id: string
13
17
  serverName: string
14
18
  transport: 'stdio' | 'streamable-http'
15
19
  disabled: boolean
20
+ scope: McpScope
21
+ /** Profile rows only: a global row with the same id composes after this one and wins. */
22
+ shadowed?: boolean
16
23
  command?: string
17
24
  args?: string[]
18
25
  env?: Record<string, string>
@@ -22,10 +29,10 @@ export interface McpRow {
22
29
  }
23
30
 
24
31
  export interface McpInjected {
25
- list(): Promise<{ servers: McpRow[] }>
32
+ list(): Promise<{ servers: McpRow[]; globalError?: string }>
26
33
  save(input: Record<string, unknown>): Promise<{ ok: boolean; id: string }>
27
- toggle(id: string, disabled: boolean): Promise<{ ok: boolean }>
28
- remove(id: string): Promise<{ ok: boolean }>
34
+ toggle(id: string, disabled: boolean, scope: McpScope): Promise<{ ok: boolean }>
35
+ remove(id: string, scope: McpScope): Promise<{ ok: boolean }>
29
36
  scanImport(): Promise<{ servers: ImportedServerView[]; existing: string[] }>
30
37
  applyImport(items: Array<{ agent: string; name: string }>): Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>
31
38
  restart(): Promise<void>
@@ -35,6 +42,8 @@ export interface McpInjected {
35
42
  /** Editor dialog state; null when closed. Textareas hold line-based values. */
36
43
  interface EditorState {
37
44
  id: string
45
+ /** Fixed after create: a row is edited in the layer that holds it. */
46
+ scope: McpScope
38
47
  serverName: string
39
48
  transport: 'stdio' | 'streamable-http'
40
49
  command: string
@@ -208,7 +217,8 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
208
217
  const { t, injected } = props
209
218
  const [servers, setServers] = useState<McpRow[] | null>(null)
210
219
  const [editor, setEditor] = useState<EditorState | null>(null)
211
- const [confirmId, setConfirmId] = useState<string | null>(null)
220
+ const [confirmRow, setConfirmRow] = useState<McpRow | null>(null)
221
+ const [globalError, setGlobalError] = useState<string | null>(null)
212
222
  const [importOpen, setImportOpen] = useState(false)
213
223
  const [importItems, setImportItems] = useState<Array<{ server: ImportedServerView; existing: boolean; checked: boolean }> | null>(null)
214
224
  const [busy, setBusy] = useState(false)
@@ -266,7 +276,11 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
266
276
  useEffect(() => {
267
277
  let current = true
268
278
  void injected.list().then(
269
- (body) => { if (current) setServers(body.servers) },
279
+ (body) => {
280
+ if (!current) return
281
+ setServers(body.servers)
282
+ setGlobalError(body.globalError ?? null)
283
+ },
270
284
  (error: Error) => { if (current) { setServers([]); setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }) } },
271
285
  )
272
286
  return () => { current = false }
@@ -276,7 +290,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
276
290
  setFormError(null)
277
291
  setPasteError(null)
278
292
  setPasteJson('')
279
- setEditor({ id: '', serverName: '', transport: 'stdio', command: '', args: '', env: '', url: '', headers: '' })
293
+ setEditor({ id: '', scope: 'profile', serverName: '', transport: 'stdio', command: '', args: '', env: '', url: '', headers: '' })
280
294
  }
281
295
 
282
296
  const openEdit = (row: McpRow): void => {
@@ -285,6 +299,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
285
299
  setPasteJson('')
286
300
  setEditor({
287
301
  id: row.id,
302
+ scope: row.scope,
288
303
  serverName: row.serverName,
289
304
  transport: row.transport,
290
305
  command: row.command ?? '',
@@ -336,6 +351,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
336
351
  try {
337
352
  await injected.save({
338
353
  id: editor.id,
354
+ scope: editor.scope,
339
355
  serverName: editor.serverName.trim(),
340
356
  transport: editor.transport,
341
357
  ...(editor.transport === 'stdio'
@@ -362,7 +378,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
362
378
  const doToggle = async (row: McpRow): Promise<void> => {
363
379
  setBusy(true)
364
380
  try {
365
- await injected.toggle(row.id, !row.disabled)
381
+ await injected.toggle(row.id, !row.disabled, row.scope)
366
382
  setOutcome({ ok: true, text: t('restartNeeded') })
367
383
  reloadList(true)
368
384
  } catch (error) {
@@ -373,17 +389,17 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
373
389
  }
374
390
 
375
391
  const doRemove = async (): Promise<void> => {
376
- if (confirmId === null) return
392
+ if (confirmRow === null) return
377
393
  setBusy(true)
378
394
  try {
379
- await injected.remove(confirmId)
395
+ await injected.remove(confirmRow.id, confirmRow.scope)
380
396
  setOutcome({ ok: true, text: t('restartNeeded') })
381
397
  reloadList(true)
382
398
  } catch (error) {
383
399
  setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
384
400
  } finally {
385
401
  setBusy(false)
386
- setConfirmId(null)
402
+ setConfirmRow(null)
387
403
  }
388
404
  }
389
405
 
@@ -442,6 +458,15 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
442
458
  <div className="dpc-bannerBody"><span>{outcome.text}</span></div>
443
459
  </div>
444
460
  )}
461
+ {globalError !== null && (
462
+ <div className="dpc-banner" data-kind="error" role="alert">
463
+ <StateDot state="error" size={10} />
464
+ <div className="dpc-bannerBody">
465
+ <span>{t('globalLayerError')}</span>
466
+ <span className="dpc-bannerHint">{globalError}</span>
467
+ </div>
468
+ </div>
469
+ )}
445
470
  {(pending || restarting) && restartBanner}
446
471
 
447
472
  <div className="dpc-listHead">
@@ -458,20 +483,22 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
458
483
  {servers !== null && servers.length > 0 && (
459
484
  <ul className="dpc-cards">
460
485
  {servers.map((row) => (
461
- <li className="dpc-card" key={row.id}>
486
+ <li className="dpc-card" key={`${row.scope}/${row.id}`}>
462
487
  <div className="dpc-cardTop">
463
488
  <strong className="dpc-cardTitle" title={row.id}>{row.serverName}</strong>
489
+ <span className="dpc-tag" data-kind={row.scope === 'global' ? 'source' : undefined}>{row.scope === 'global' ? t('scopeGlobal') : t('scopeProfile')}</span>
464
490
  <span className="dpc-tag">{row.transport}</span>
465
491
  <span className="dpc-tag" data-kind={row.disabled ? 'off' : undefined}>{row.disabled ? t('disabled') : t('enabled')}</span>
466
492
  </div>
467
493
  <p className="dpc-cardDesc">
468
494
  {row.transport === 'stdio' ? `${row.command ?? ''} ${(row.args ?? []).join(' ')}` : row.url ?? ''}
469
495
  </p>
496
+ {row.shadowed === true && <p className="dpc-formError">{t('shadowedByGlobal')}</p>}
470
497
  <div className="dpc-cardRow">
471
498
  <span className="dpc-spacer" />
472
499
  <Button variant="ghost" size="sm" disabled={busy} onClick={() => void doToggle(row)}>{t('toggle')}</Button>
473
500
  <Button variant="ghost" size="sm" disabled={busy} onClick={() => openEdit(row)}>{t('edit')}</Button>
474
- <Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmId(row.id)}>{t('delete')}</Button>
501
+ <Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmRow(row)}>{t('delete')}</Button>
475
502
  </div>
476
503
  </li>
477
504
  ))}
@@ -487,6 +514,19 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
487
514
  >
488
515
  {editor !== null && (
489
516
  <div className="dpc-form">
517
+ <label className="dpc-label">
518
+ <span>{t('scopeLabel')}</span>
519
+ <select
520
+ className="dpc-select"
521
+ value={editor.scope}
522
+ disabled={editor.id !== ''}
523
+ onChange={(event) => setEditor({ ...editor, scope: event.target.value as McpScope })}
524
+ >
525
+ <option value="profile">{t('scopeProfile')}</option>
526
+ <option value="global">{t('scopeGlobal')}</option>
527
+ </select>
528
+ <span className="dpc-formatHint">{t('scopeHint')}</span>
529
+ </label>
490
530
  <label className="dpc-label">
491
531
  <span>{t('serverName')}</span>
492
532
  <input
@@ -582,13 +622,13 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
582
622
  </Modal>
583
623
 
584
624
  <Modal
585
- open={confirmId !== null}
586
- onClose={() => setConfirmId(null)}
625
+ open={confirmRow !== null}
626
+ onClose={() => setConfirmRow(null)}
587
627
  title={t('confirmRemove')}
588
- description={confirmId ?? undefined}
628
+ description={confirmRow?.serverName ?? undefined}
589
629
  footer={
590
630
  <>
591
- <Button variant="ghost" onClick={() => setConfirmId(null)}>{t('cancel')}</Button>
631
+ <Button variant="ghost" onClick={() => setConfirmRow(null)}>{t('cancel')}</Button>
592
632
  <Button variant="primary" disabled={busy} onClick={() => void doRemove()}>{t('delete')}</Button>
593
633
  </>
594
634
  }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { createElement as h } from 'react'
6
6
  import { CapabilitiesSection } from './CapabilitiesSection.tsx'
7
- import type { McpInjected, McpRow } from './McpTab.tsx'
7
+ import type { McpInjected, McpRow, McpScope } from './McpTab.tsx'
8
8
  import type { MarketInjected, RootRowView } from './MarketTab.tsx'
9
9
  import type { SkillsInjected, SkillRowView } from './SkillsTab.tsx'
10
10
  import { zh, en } from './locales.ts'
@@ -89,10 +89,11 @@ export function apply(ctx: CapabilitiesClientContext): void {
89
89
  }
90
90
 
91
91
  const mcpInjected: McpInjected = {
92
- list: () => fetchJson<{ servers: McpRow[] }>('/dsh-plugin-capabilities/mcp'),
92
+ list: () => fetchJson<{ servers: McpRow[]; globalError?: string }>('/dsh-plugin-capabilities/mcp'),
93
93
  save: (input: unknown) => post('/dsh-plugin-capabilities/mcp/save', input) as Promise<{ ok: boolean; id: string }>,
94
- toggle: (id: string, disabled: boolean) => post('/dsh-plugin-capabilities/mcp/toggle', { id, disabled }) as Promise<{ ok: boolean }>,
95
- remove: (id: string) => post('/dsh-plugin-capabilities/mcp/remove', { id }) as Promise<{ ok: boolean }>,
94
+ toggle: (id: string, disabled: boolean, scope: McpScope) =>
95
+ post('/dsh-plugin-capabilities/mcp/toggle', { id, disabled, scope }) as Promise<{ ok: boolean }>,
96
+ remove: (id: string, scope: McpScope) => post('/dsh-plugin-capabilities/mcp/remove', { id, scope }) as Promise<{ ok: boolean }>,
96
97
  scanImport: () => fetchJson<{ servers: ImportedServerView[]; existing: string[] }>('/dsh-plugin-capabilities/import/scan'),
97
98
  applyImport: (items: Array<{ agent: string; name: string }>) =>
98
99
  post('/dsh-plugin-capabilities/import/apply', { items }) as Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>,
@@ -68,9 +68,15 @@ 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)读取失败,全局服务器暂未显示:',
74
80
  serverName: '服务器名(工具名前缀)',
75
81
  transport: '传输方式',
76
82
  transportStdio: 'stdio(本地命令)',
@@ -84,7 +90,7 @@ export const zh = {
84
90
  enabled: '启用中',
85
91
  toggle: '停用/启用',
86
92
  confirmRemove: '确认移除该服务器?',
87
- removeWarn: '将从 profile 配置中移除这一行,重启 dsh 后其工具不再出现。',
93
+ removeWarn: '将从配置中移除这一行,重启 dsh 后其工具不再出现。',
88
94
  emptyMcp: '还没有配置 MCP 服务器',
89
95
  importServers: '从其他 Agent 导入',
90
96
  importIntro: '扫描 Claude Code(~/.claude.json)与 Codex(~/.codex/config.toml)的 MCP 服务器配置,勾选后导入为本 profile 的服务器行。',
@@ -201,9 +207,15 @@ export const en = {
201
207
  confirmRemoveRoot: 'Remove this repository?',
202
208
  removeRootWarn: 'Stops scanning the repository and deletes the copy downloaded into DSH_HOME (a local folder itself is untouched).',
203
209
  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.',
210
+ 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
211
  addServer: 'Add server',
206
212
  editServer: 'Edit server',
213
+ scopeLabel: 'Scope',
214
+ scopeGlobal: 'Global',
215
+ scopeProfile: 'This profile',
216
+ scopeHint: 'Global writes DSH_HOME/cordis.patch.yml and applies to every profile on this machine; this profile writes the current profile only.',
217
+ shadowedByGlobal: 'a global row with the same id wins — this row has no effect',
218
+ globalLayerError: 'Failed to read the global layer (DSH_HOME/cordis.patch.yml); global servers are hidden:',
207
219
  serverName: 'Server name (tool name prefix)',
208
220
  transport: 'Transport',
209
221
  transportStdio: 'stdio (local command)',
@@ -217,7 +229,7 @@ export const en = {
217
229
  enabled: 'Enabled',
218
230
  toggle: 'Enable/disable',
219
231
  confirmRemove: 'Remove this server?',
220
- removeWarn: 'Removes the row from the profile configuration; its tools disappear after the next dsh restart.',
232
+ removeWarn: 'Removes the row from its configuration; its tools disappear after the next dsh restart.',
221
233
  emptyMcp: 'No MCP servers configured yet',
222
234
  importServers: 'Import from other agents',
223
235
  importIntro: 'Scans Claude Code (~/.claude.json) and Codex (~/.codex/config.toml) MCP server configs; selected entries become server rows in this profile.',
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,10 +2,11 @@ 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 { listMcp, listMcpScoped, mcpScopeDir, removeMcp, 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
+ const home = join(root, 'home')
9
10
  afterAll(() => rmSync(root, { recursive: true, force: true }))
10
11
 
11
12
  const patch = () => join(profile, 'cordis.patch.yml')
@@ -149,3 +150,67 @@ describe('corrupt patch file', () => {
149
150
  expect(readFileSync(join(brokenDir, 'cordis.patch.yml'), 'utf8')).toBe(before)
150
151
  })
151
152
  })
153
+
154
+ describe('global patch layer (GitHub issue #2)', () => {
155
+ const cleanHome = (): void => rmSync(join(home, 'cordis.patch.yml'), { force: true })
156
+
157
+ it('merges both layers, global first, and flags shadowed profile rows', () => {
158
+ cleanHome()
159
+ mkdirSync(home, { recursive: true })
160
+ writeFileSync(join(home, 'cordis.patch.yml'), [
161
+ '- insert:',
162
+ ' - id: mcp-shared-tools',
163
+ " name: '@deepseek-ai/dsh-mcp-client'",
164
+ ' config:',
165
+ ' serverName: shared-tools',
166
+ ' transport: stdio',
167
+ ' command: node',
168
+ ' args:',
169
+ ' - global.js',
170
+ '',
171
+ ].join('\n'), 'utf8')
172
+
173
+ upsertMcp(profile, { ...stdio, serverName: 'github' })
174
+ // Same id as the global row: the home layer composes after the profile
175
+ // layer, so this profile row never takes effect.
176
+ upsertMcp(profile, { ...stdio, serverName: 'shared', id: 'mcp-shared-tools' })
177
+
178
+ const { servers, globalError } = listMcpScoped(profile, home)
179
+ expect(globalError).toBeUndefined()
180
+ expect(servers.map(row => `${row.scope}/${row.id}`)).toEqual(['global/mcp-shared-tools', 'profile/mcp-github', 'profile/mcp-shared-tools'])
181
+ expect(servers[0]).toMatchObject({ serverName: 'shared-tools', args: ['global.js'] })
182
+ expect(servers[2].shadowed).toBe(true)
183
+ expect(servers[1].shadowed).toBeUndefined()
184
+
185
+ cleanHome()
186
+ expect(listMcpScoped(profile, home).servers.every(row => row.scope === 'profile')).toBe(true)
187
+ })
188
+
189
+ it('degrades a broken global file to globalError and still lists profile rows', () => {
190
+ mkdirSync(home, { recursive: true })
191
+ writeFileSync(join(home, 'cordis.patch.yml'), 'foo: 1\n bar: 2\n', 'utf8')
192
+ const { servers, globalError } = listMcpScoped(profile, home)
193
+ expect(globalError).toMatch(/cordis\.patch\.yml/)
194
+ expect(globalError).toMatch(/line 1/)
195
+ expect(servers.length).toBeGreaterThan(0)
196
+ expect(servers.every(row => row.scope === 'profile')).toBe(true)
197
+ cleanHome()
198
+ })
199
+
200
+ it('writes, toggles, and removes through the global scope dir', () => {
201
+ cleanHome()
202
+ const globalDir = mcpScopeDir('global', profile, home)
203
+ expect(globalDir).toBe(home)
204
+
205
+ const id = upsertMcp(globalDir, { ...stdio, serverName: 'shared-tools' })
206
+ expect(id).toBe('mcp-shared-tools')
207
+ // The global layer file (not the profile's) received the row.
208
+ expect(readFileSync(join(home, 'cordis.patch.yml'), 'utf8')).toContain('shared-tools')
209
+
210
+ expect(setMcpDisabled(globalDir, id, true)).toBe(true)
211
+ expect(listMcp(home)[0].disabled).toBe(true)
212
+ expect(removeMcp(globalDir, id)).toBe(true)
213
+ expect(listMcp(home)).toHaveLength(0)
214
+ expect(listMcp(profile).some(row => row.serverName === 'shared-tools')).toBe(false)
215
+ })
216
+ })
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,6 @@ 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
  }
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
  }