dsh-plugin-capabilities 0.3.6 → 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/README.md +3 -1
- package/lib/client.js +63 -20
- package/lib/client.js.map +2 -2
- package/lib/index.js +79 -30
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
- package/src/client/MarketTab.tsx +1 -1
- package/src/client/McpTab.tsx +59 -19
- package/src/client/index.ts +5 -4
- package/src/client/locales.ts +16 -4
- package/src/index.ts +2 -1
- package/src/mcp.test.ts +83 -2
- package/src/mcp.ts +96 -18
- package/src/profile.ts +6 -2
- package/src/routes.ts +36 -21
package/package.json
CHANGED
package/src/client/MarketTab.tsx
CHANGED
|
@@ -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
|
}
|
package/src/client/McpTab.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/** Settings → Plugins “MCP” tab: manage the
|
|
2
|
-
* Mutations rewrite the
|
|
3
|
-
* hands the restart to the desktop shell when one
|
|
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 [
|
|
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) => {
|
|
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 (
|
|
392
|
+
if (confirmRow === null) return
|
|
377
393
|
setBusy(true)
|
|
378
394
|
try {
|
|
379
|
-
await injected.remove(
|
|
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
|
-
|
|
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={() =>
|
|
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={
|
|
586
|
-
onClose={() =>
|
|
625
|
+
open={confirmRow !== null}
|
|
626
|
+
onClose={() => setConfirmRow(null)}
|
|
587
627
|
title={t('confirmRemove')}
|
|
588
|
-
description={
|
|
628
|
+
description={confirmRow?.serverName ?? undefined}
|
|
589
629
|
footer={
|
|
590
630
|
<>
|
|
591
|
-
<Button variant="ghost" onClick={() =>
|
|
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
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
95
|
-
|
|
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 }> }>,
|
package/src/client/locales.ts
CHANGED
|
@@ -68,9 +68,15 @@ 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)读取失败,全局服务器暂未显示:',
|
|
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: '
|
|
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
|
|
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
|
|
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
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
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')
|
|
@@ -133,3 +134,83 @@ describe('profile patch CRUD', () => {
|
|
|
133
134
|
expect(listMcp(profile)).toHaveLength(0)
|
|
134
135
|
})
|
|
135
136
|
})
|
|
137
|
+
|
|
138
|
+
describe('corrupt patch file', () => {
|
|
139
|
+
it('fails reads and writes with the file path and parser location, writing nothing', () => {
|
|
140
|
+
// GitHub issue #1: a malformed cordis.patch.yml surfaced as the raw
|
|
141
|
+
// "Document with errors cannot be stringified" when a write hit String(doc).
|
|
142
|
+
const brokenDir = join(root, 'profiles', 'broken')
|
|
143
|
+
mkdirSync(brokenDir, { recursive: true })
|
|
144
|
+
writeFileSync(join(brokenDir, 'cordis.patch.yml'), 'foo: 1\n bar: 2\n', 'utf8')
|
|
145
|
+
const before = readFileSync(join(brokenDir, 'cordis.patch.yml'), 'utf8')
|
|
146
|
+
|
|
147
|
+
expect(() => listMcp(brokenDir)).toThrow(/cordis\.patch\.yml/)
|
|
148
|
+
expect(() => listMcp(brokenDir)).toThrow(/line 1/)
|
|
149
|
+
expect(() => upsertMcp(brokenDir, stdio)).toThrow(/未写入任何内容/)
|
|
150
|
+
expect(readFileSync(join(brokenDir, 'cordis.patch.yml'), 'utf8')).toBe(before)
|
|
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,14 +47,46 @@ 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
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Refuse to touch a patch file the parser could not fully read. parseDocument
|
|
69
|
+
* parks syntax errors in doc.errors (it does not throw), and the failure only
|
|
70
|
+
* resurfaces at String(doc) as the internals-leaking "Document with errors
|
|
71
|
+
* cannot be stringified". Nothing gets written either way, but the banner must
|
|
72
|
+
* say what is actually wrong and which file to fix.
|
|
73
|
+
*/
|
|
74
|
+
function assertPatchParses(path: string, doc: Document): void {
|
|
75
|
+
if (doc.errors.length === 0) return
|
|
76
|
+
const at = String(doc.errors[0].message).split('\n', 1)[0]
|
|
77
|
+
const total = doc.errors.length > 1 ? `, +${doc.errors.length - 1} more` : ''
|
|
78
|
+
throw new Error(
|
|
79
|
+
`配置文件 ${path} 存在 YAML 语法错误(${at}${total}),未写入任何内容,请修复该文件后重试`
|
|
80
|
+
+ ` / YAML syntax error in the patch file (${at}${total}); nothing was written — fix it and retry`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
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')
|
|
47
87
|
const text = existsSync(path) ? readFileSync(path, 'utf8') : '[]'
|
|
48
88
|
const doc = parseDocument(text)
|
|
89
|
+
assertPatchParses(path, doc)
|
|
49
90
|
const contents = doc.contents as YAMLSeq | null
|
|
50
91
|
// The default-empty file parses as a flow `[]`; the patch layer is
|
|
51
92
|
// human-edited block YAML, so flip the flag before anything appends.
|
|
@@ -53,9 +94,9 @@ function loadPatch(profileDirPath: string): Document {
|
|
|
53
94
|
return doc
|
|
54
95
|
}
|
|
55
96
|
|
|
56
|
-
function savePatch(
|
|
57
|
-
mkdirSync(
|
|
58
|
-
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')
|
|
59
100
|
}
|
|
60
101
|
|
|
61
102
|
/** Wrap a plain value into a YAML node (yaml v2 exposes no standalone createNode). */
|
|
@@ -165,12 +206,49 @@ function takenIds(doc: Document): Set<string> {
|
|
|
165
206
|
return taken
|
|
166
207
|
}
|
|
167
208
|
|
|
168
|
-
/** Read every mcp-client row in
|
|
169
|
-
export function listMcp(
|
|
170
|
-
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)
|
|
171
212
|
return mcpRowItems(doc).map(({ node }) => rowToMcp(doc, node))
|
|
172
213
|
}
|
|
173
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
|
+
|
|
174
252
|
/** Validate one write request; returns the rejection reason or null. */
|
|
175
253
|
export function validateMcpInput(input: McpInput): string | null {
|
|
176
254
|
if (!SERVER_NAME_RE.test(input.serverName)) return 'serverName must be 1-32 chars of A-Z a-z 0-9 _ -'
|
|
@@ -186,9 +264,9 @@ export function validateMcpInput(input: McpInput): string | null {
|
|
|
186
264
|
}
|
|
187
265
|
|
|
188
266
|
/** Add or replace one server row. Returns the (possibly deduplicated) id. */
|
|
189
|
-
export function upsertMcp(
|
|
267
|
+
export function upsertMcp(dirPath: string, input: McpInput): string {
|
|
190
268
|
const inputId = input.id ?? ''
|
|
191
|
-
const doc = loadPatch(
|
|
269
|
+
const doc = loadPatch(dirPath)
|
|
192
270
|
const list = managedInsert(doc)
|
|
193
271
|
|
|
194
272
|
const existing = inputId !== ''
|
|
@@ -230,25 +308,25 @@ export function upsertMcp(profileDirPath: string, input: McpInput): string {
|
|
|
230
308
|
rowSeq(doc).items.splice(rowSeq(doc).items.indexOf(existing.node), 1, node)
|
|
231
309
|
}
|
|
232
310
|
|
|
233
|
-
savePatch(
|
|
311
|
+
savePatch(dirPath, doc)
|
|
234
312
|
return id
|
|
235
313
|
}
|
|
236
314
|
|
|
237
315
|
/** Flip one row's disabled flag (absent = enabled). Returns false when missing. */
|
|
238
|
-
export function setMcpDisabled(
|
|
239
|
-
const doc = loadPatch(
|
|
316
|
+
export function setMcpDisabled(dirPath: string, id: string, disabled: boolean): boolean {
|
|
317
|
+
const doc = loadPatch(dirPath)
|
|
240
318
|
managedInsert(doc)
|
|
241
319
|
const hit = mcpRowItems(doc).find(({ node }) => String(node.get('id') ?? '') === id)
|
|
242
320
|
if (hit === undefined) return false
|
|
243
321
|
if (disabled) hit.node.set('disabled', true)
|
|
244
322
|
else hit.node.delete('disabled')
|
|
245
|
-
savePatch(
|
|
323
|
+
savePatch(dirPath, doc)
|
|
246
324
|
return true
|
|
247
325
|
}
|
|
248
326
|
|
|
249
327
|
/** Remove one server row. Returns false when missing. */
|
|
250
|
-
export function removeMcp(
|
|
251
|
-
const doc = loadPatch(
|
|
328
|
+
export function removeMcp(dirPath: string, id: string): boolean {
|
|
329
|
+
const doc = loadPatch(dirPath)
|
|
252
330
|
managedInsert(doc)
|
|
253
331
|
const hit = mcpRowItems(doc).find(({ node }) => String(node.get('id') ?? '') === id)
|
|
254
332
|
if (hit === undefined || hit.list === undefined) return false
|
|
@@ -262,6 +340,6 @@ export function removeMcp(profileDirPath: string, id: string): boolean {
|
|
|
262
340
|
seq.items.splice(seq.items.indexOf(owner), 1)
|
|
263
341
|
}
|
|
264
342
|
|
|
265
|
-
savePatch(
|
|
343
|
+
savePatch(dirPath, doc)
|
|
266
344
|
return true
|
|
267
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
|
-
|
|
16
|
-
return join(home, 'profiles', profile)
|
|
20
|
+
return join(dshHomeDir(dshHome), 'profiles', profile)
|
|
17
21
|
}
|