dsh-plugin-capabilities 0.1.2 → 0.1.4
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 +2 -2
- package/lib/client.js +77 -22
- package/lib/client.js.map +2 -2
- package/lib/index.js +99 -34
- package/lib/index.js.map +3 -3
- package/package.json +15 -3
- package/src/client/McpTab.tsx +81 -29
- package/src/client/css.ts +9 -0
- package/src/client/locales.ts +2 -0
- package/src/mcp.test.ts +42 -0
- package/src/mcp.ts +132 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-capabilities",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -14,7 +14,14 @@
|
|
|
14
14
|
"url": "https://github.com/qinyre/dsh-plugin-capabilities/issues"
|
|
15
15
|
},
|
|
16
16
|
"homepage": "https://github.com/qinyre/dsh-plugin-capabilities#readme",
|
|
17
|
-
"keywords": [
|
|
17
|
+
"keywords": [
|
|
18
|
+
"deepseek",
|
|
19
|
+
"harness",
|
|
20
|
+
"dsh",
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"skills",
|
|
23
|
+
"mcp"
|
|
24
|
+
],
|
|
18
25
|
"dsh": {
|
|
19
26
|
"bundle": {
|
|
20
27
|
"patch": "./cordis.patch.yml"
|
|
@@ -36,7 +43,12 @@
|
|
|
36
43
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
37
44
|
"./package.json": "./package.json"
|
|
38
45
|
},
|
|
39
|
-
"files": [
|
|
46
|
+
"files": [
|
|
47
|
+
"lib",
|
|
48
|
+
"src",
|
|
49
|
+
"cordis.patch.yml",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
40
52
|
"scripts": {
|
|
41
53
|
"build": "node scripts/build-node.mjs && node scripts/build-client.mjs && node scripts/verify-bundle.mjs",
|
|
42
54
|
"prepare": "node scripts/build-node.mjs && node scripts/build-client.mjs && node scripts/verify-bundle.mjs",
|
package/src/client/McpTab.tsx
CHANGED
|
@@ -44,6 +44,26 @@ interface EditorState {
|
|
|
44
44
|
headers: string
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** One import entry together with its index in the flat state array —
|
|
48
|
+
* checkbox handlers rewrite the flat array, so indices must survive grouping. */
|
|
49
|
+
type ImportItem = { server: ImportedServerView; existing: boolean; checked: boolean }
|
|
50
|
+
|
|
51
|
+
/** Group import candidates by source agent, known agents first. */
|
|
52
|
+
export function importGroups(items: ImportItem[]): Array<{ agent: string; label: string; items: Array<{ item: ImportItem; index: number }> }> {
|
|
53
|
+
const label = (agent: string) => agent === 'claude-code' ? 'Claude Code' : agent === 'codex' ? 'Codex' : agent
|
|
54
|
+
const order = ['claude-code', 'codex']
|
|
55
|
+
const agents = [...new Set(items.map(item => item.server.agent))]
|
|
56
|
+
.sort((a, b) => {
|
|
57
|
+
const rank = (agent: string) => { const at = order.indexOf(agent); return at === -1 ? order.length : at }
|
|
58
|
+
return rank(a) - rank(b) || a.localeCompare(b)
|
|
59
|
+
})
|
|
60
|
+
return agents.map(agent => ({
|
|
61
|
+
agent,
|
|
62
|
+
label: label(agent),
|
|
63
|
+
items: items.map((item, index) => ({ item, index })).filter(({ item }) => item.server.agent === agent),
|
|
64
|
+
}))
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
/** KEY=VALUE / KEY: VALUE lines to a map. */
|
|
48
68
|
function parsePairs(text: string, separator: ':' | '='): Record<string, string> {
|
|
49
69
|
const map: Record<string, string> = {}
|
|
@@ -394,41 +414,73 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
|
|
|
394
414
|
open={importOpen}
|
|
395
415
|
onClose={() => setImportOpen(false)}
|
|
396
416
|
title={t('importServers')}
|
|
417
|
+
className="dpc-modalWide"
|
|
397
418
|
>
|
|
398
419
|
<div className="dpc-form">
|
|
399
420
|
<p className="dpc-intro" style={{ margin: 0 }}>{t('importIntro')}</p>
|
|
400
421
|
{importItems === null && <p className="dpc-empty">{t('loading')}</p>}
|
|
401
422
|
{importItems !== null && importItems.length === 0 && <p className="dpc-empty">{t('importEmpty')}</p>}
|
|
402
423
|
{importItems !== null && importItems.length > 0 && (
|
|
403
|
-
<
|
|
404
|
-
{importItems.map(
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
424
|
+
<div className="dpc-importScroll">
|
|
425
|
+
{importGroups(importItems).map(group => {
|
|
426
|
+
const selectable = group.items
|
|
427
|
+
.filter(({ item }) => !item.existing)
|
|
428
|
+
.map(({ index }) => index)
|
|
429
|
+
const allChecked = selectable.length > 0
|
|
430
|
+
&& selectable.every(index => importItems[index].checked)
|
|
431
|
+
return (
|
|
432
|
+
<section className="dpc-importGroup" key={group.agent}>
|
|
433
|
+
<div className="dpc-importHead">
|
|
434
|
+
<span className="dpc-tag" data-kind="source">{group.label}</span>
|
|
435
|
+
<span className="dpc-importCount">{group.items.length}</span>
|
|
436
|
+
{selectable.length > 0 && (
|
|
437
|
+
<label className="dpc-importAll">
|
|
438
|
+
<input
|
|
439
|
+
type="checkbox"
|
|
440
|
+
checked={allChecked}
|
|
441
|
+
onChange={(event) => {
|
|
442
|
+
const next = importItems.slice()
|
|
443
|
+
for (const index of selectable) next[index] = { ...next[index], checked: event.target.checked }
|
|
444
|
+
setImportItems(next)
|
|
445
|
+
}}
|
|
446
|
+
/>
|
|
447
|
+
{t('importSelectAll')}
|
|
448
|
+
</label>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
451
|
+
<ul className="dpc-cards" style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}>
|
|
452
|
+
{group.items.map(({ item, index }) => {
|
|
453
|
+
const command = item.server.transport === 'stdio'
|
|
454
|
+
? `${item.server.command ?? ''} ${(item.server.args ?? []).join(' ')}`.trim()
|
|
455
|
+
: item.server.url ?? ''
|
|
456
|
+
return (
|
|
457
|
+
<li className="dpc-card" key={`${item.server.agent}/${item.server.name}`} style={{ opacity: item.existing ? 0.55 : 1 }}>
|
|
458
|
+
<div className="dpc-cardTop">
|
|
459
|
+
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0, cursor: item.existing ? 'default' : 'pointer' }}>
|
|
460
|
+
<input
|
|
461
|
+
type="checkbox"
|
|
462
|
+
checked={item.checked}
|
|
463
|
+
disabled={item.existing}
|
|
464
|
+
onChange={(event) => {
|
|
465
|
+
const next = importItems.slice()
|
|
466
|
+
next[index] = { ...item, checked: event.target.checked }
|
|
467
|
+
setImportItems(next)
|
|
468
|
+
}}
|
|
469
|
+
/>
|
|
470
|
+
<strong className="dpc-cardTitle" title={item.server.name}>{item.server.name}</strong>
|
|
471
|
+
</label>
|
|
472
|
+
<span className="dpc-tag">{item.server.transport}</span>
|
|
473
|
+
{item.existing && <span className="dpc-tag">{t('importExisting')}</span>}
|
|
474
|
+
</div>
|
|
475
|
+
<p className="dpc-cardDesc" title={command}>{command}</p>
|
|
476
|
+
</li>
|
|
477
|
+
)
|
|
478
|
+
})}
|
|
479
|
+
</ul>
|
|
480
|
+
</section>
|
|
481
|
+
)
|
|
482
|
+
})}
|
|
483
|
+
</div>
|
|
432
484
|
)}
|
|
433
485
|
{formError !== null && <p className="dpc-formError">{formError}</p>}
|
|
434
486
|
<div className="dpc-cardRow">
|
package/src/client/css.ts
CHANGED
|
@@ -43,5 +43,14 @@ export const CSS = `
|
|
|
43
43
|
.dpc-checks{display:flex;gap:16px;font-size:13px;line-height:20px}
|
|
44
44
|
.dpc-checks label{display:inline-flex;align-items:center;gap:6px;cursor:pointer}
|
|
45
45
|
.dpc-formError{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}
|
|
46
|
+
/* Import dialog: wider than the host's 380px default; the server list gets
|
|
47
|
+
its own scroll so title, intro, and footer stay pinned. The doubled class
|
|
48
|
+
beats the host dialog's module CSS regardless of injection order. */
|
|
49
|
+
.dpc-modalWide.dpc-modalWide{width:min(680px,100%)}
|
|
50
|
+
.dpc-importScroll{display:flex;flex-direction:column;gap:14px;max-height:min(400px,52vh);overflow-y:auto;padding:2px 4px 2px 2px}
|
|
51
|
+
.dpc-importGroup{display:flex;flex-direction:column;gap:8px}
|
|
52
|
+
.dpc-importHead{display:flex;align-items:center;gap:8px;padding:0 2px}
|
|
53
|
+
.dpc-importCount{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
|
|
54
|
+
.dpc-importAll{margin-left:auto;display:inline-flex;align-items:center;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
|
46
55
|
@media(max-width:680px){.dpc-cards{grid-template-columns:minmax(0,1fr)}}
|
|
47
56
|
`
|
package/src/client/locales.ts
CHANGED
|
@@ -56,6 +56,7 @@ export const zh = {
|
|
|
56
56
|
importServers: '从其他 Agent 导入',
|
|
57
57
|
importIntro: '扫描 Claude Code(~/.claude.json)与 Codex(~/.codex/config.toml)的 MCP 服务器配置,勾选后导入为本 profile 的服务器行。',
|
|
58
58
|
importEmpty: '没有发现可导入的 MCP 服务器',
|
|
59
|
+
importSelectAll: '全选',
|
|
59
60
|
importExisting: '已存在',
|
|
60
61
|
importSelected: '导入选中项',
|
|
61
62
|
restartNeeded: '配置已写入,重启 dsh 后生效。',
|
|
@@ -126,6 +127,7 @@ export const en = {
|
|
|
126
127
|
importServers: 'Import from other agents',
|
|
127
128
|
importIntro: 'Scans Claude Code (~/.claude.json) and Codex (~/.codex/config.toml) MCP server configs; selected entries become server rows in this profile.',
|
|
128
129
|
importEmpty: 'No importable MCP servers found',
|
|
130
|
+
importSelectAll: 'Select all',
|
|
129
131
|
importExisting: 'already here',
|
|
130
132
|
importSelected: 'Import selected',
|
|
131
133
|
restartNeeded: 'Configuration written — restart dsh to apply.',
|
package/src/mcp.test.ts
CHANGED
|
@@ -44,6 +44,41 @@ describe('profile patch CRUD', () => {
|
|
|
44
44
|
expect(rows[0].env).toEqual({ GITHUB_TOKEN: 'secret' })
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
+
it('writes rows inside an anonymous insert list, never as bare entries', () => {
|
|
48
|
+
// The loader skips bare `- id:` entries whose target does not exist;
|
|
49
|
+
// only `- insert:` rows mount. This is the contract that made 0.1.2
|
|
50
|
+
// rows invisible to the agent.
|
|
51
|
+
const text = readFileSync(patch(), 'utf8')
|
|
52
|
+
expect(text).toContain('- insert:')
|
|
53
|
+
expect(text).not.toMatch(/^- id: mcp-/m)
|
|
54
|
+
expect(text).toMatch(/^ {4}- id: mcp-github$/m)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('absorbs legacy bare rows into the insert list on the next write', () => {
|
|
58
|
+
writeFileSync(patch(), [
|
|
59
|
+
'- id: dsh-market',
|
|
60
|
+
' config:',
|
|
61
|
+
' allowRestart: false',
|
|
62
|
+
'- id: mcp-open-websearch',
|
|
63
|
+
' name: "@deepseek-ai/dsh-mcp-client"',
|
|
64
|
+
' config:',
|
|
65
|
+
' serverName: open-websearch',
|
|
66
|
+
' transport: stdio',
|
|
67
|
+
' command: npx',
|
|
68
|
+
'',
|
|
69
|
+
].join('\n'))
|
|
70
|
+
|
|
71
|
+
expect(listMcp(profile)).toHaveLength(1)
|
|
72
|
+
expect(listMcp(profile)[0]).toMatchObject({ id: 'mcp-open-websearch', serverName: 'open-websearch' })
|
|
73
|
+
|
|
74
|
+
upsertMcp(profile, { id: '', serverName: 'context7', transport: 'stdio', command: 'npx' })
|
|
75
|
+
const text = readFileSync(patch(), 'utf8')
|
|
76
|
+
expect(text).not.toMatch(/^- id: mcp-open-websearch/m)
|
|
77
|
+
expect(text).toMatch(/^ {4}- id: mcp-open-websearch$/m)
|
|
78
|
+
expect(text).toContain('dsh-market')
|
|
79
|
+
expect(listMcp(profile)).toHaveLength(2)
|
|
80
|
+
})
|
|
81
|
+
|
|
47
82
|
it('preserves foreign rows and comments across edits', () => {
|
|
48
83
|
writeFileSync(patch(), [
|
|
49
84
|
'# user comment',
|
|
@@ -90,4 +125,11 @@ describe('profile patch CRUD', () => {
|
|
|
90
125
|
expect(listMcp(profile).find(row => row.id === 'mcp-github')).toBeUndefined()
|
|
91
126
|
expect(removeMcp(profile, 'mcp-github')).toBe(false)
|
|
92
127
|
})
|
|
128
|
+
|
|
129
|
+
it('drops the insert entry once its last row is removed', () => {
|
|
130
|
+
for (const row of listMcp(profile)) removeMcp(profile, row.id)
|
|
131
|
+
const text = readFileSync(patch(), 'utf8')
|
|
132
|
+
expect(text).not.toContain('- insert:')
|
|
133
|
+
expect(listMcp(profile)).toHaveLength(0)
|
|
134
|
+
})
|
|
93
135
|
})
|
package/src/mcp.ts
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* `@deepseek-ai/dsh-mcp-client` row per server. The YAML document API keeps
|
|
4
4
|
* foreign rows and comments intact across edits. Row changes need a dsh
|
|
5
5
|
* restart to compose — callers surface that as a pending-restart notice.
|
|
6
|
+
*
|
|
7
|
+
* The loader's patch grammar distinguishes creates from overrides: a bare
|
|
8
|
+
* `- id: …` entry only overrides an existing row (target missing → skipped
|
|
9
|
+
* with a warning), while new rows must live in an anonymous `- insert:`
|
|
10
|
+
* list. Managed rows therefore always sit inside one insert entry, and any
|
|
11
|
+
* legacy bare rows (written before this contract was understood) are
|
|
12
|
+
* absorbed into it on the next write.
|
|
6
13
|
*/
|
|
7
14
|
|
|
8
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
@@ -38,7 +45,12 @@ export type McpInput = Omit<McpRow, 'disabled'> & { disabled?: boolean }
|
|
|
38
45
|
function loadPatch(profileDirPath: string): Document {
|
|
39
46
|
const path = join(profileDirPath, 'cordis.patch.yml')
|
|
40
47
|
const text = existsSync(path) ? readFileSync(path, 'utf8') : '[]'
|
|
41
|
-
|
|
48
|
+
const doc = parseDocument(text)
|
|
49
|
+
const contents = doc.contents as YAMLSeq | null
|
|
50
|
+
// The default-empty file parses as a flow `[]`; the patch layer is
|
|
51
|
+
// human-edited block YAML, so flip the flag before anything appends.
|
|
52
|
+
if (contents !== null && contents.flow === true && contents.items.length === 0) contents.flow = false
|
|
53
|
+
return doc
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
function savePatch(profileDirPath: string, doc: Document): void {
|
|
@@ -53,13 +65,57 @@ function toNode<T>(value: unknown): T {
|
|
|
53
65
|
|
|
54
66
|
/** The patch row sequence; an empty file's null root becomes an empty seq. */
|
|
55
67
|
function rowSeq(doc: Document): YAMLSeq<YAMLMap> {
|
|
56
|
-
if (doc.contents === null)
|
|
68
|
+
if (doc.contents === null) {
|
|
69
|
+
doc.contents = toNode<YAMLSeq<YAMLMap>>([])
|
|
70
|
+
// An empty seq defaults to flow style (`[]`); the file must stay block.
|
|
71
|
+
;(doc.contents as YAMLSeq).flow = false
|
|
72
|
+
}
|
|
57
73
|
return doc.contents as YAMLSeq<YAMLMap>
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
function isSeqNode(value: unknown): value is YAMLSeq<YAMLMap> {
|
|
77
|
+
return typeof value === 'object' && value !== null && Array.isArray((value as YAMLSeq).items)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A patch entry's insert list when it is the anonymous create form. */
|
|
81
|
+
function insertListOf(item: YAMLMap): YAMLSeq<YAMLMap> | undefined {
|
|
82
|
+
if (item.has('id')) return undefined
|
|
83
|
+
const node = item.get('insert')
|
|
84
|
+
return isSeqNode(node) ? node : undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every managed row: legacy bare entries (no list) and insert-list rows. */
|
|
88
|
+
function mcpRowItems(doc: Document): { node: YAMLMap, list?: YAMLSeq<YAMLMap> }[] {
|
|
89
|
+
const found: { node: YAMLMap, list?: YAMLSeq<YAMLMap> }[] = []
|
|
90
|
+
for (const item of rowSeq(doc).items ?? []) {
|
|
91
|
+
if (item.get('name') === MCP_PLUGIN) found.push({ node: item })
|
|
92
|
+
const list = insertListOf(item)
|
|
93
|
+
for (const row of list?.items ?? []) {
|
|
94
|
+
if (row.get('name') === MCP_PLUGIN) found.push({ node: row, list })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return found
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Map one row node to its browser-facing shape. */
|
|
101
|
+
function rowToMcp(doc: Document, item: YAMLMap): McpRow {
|
|
102
|
+
// config is a YAMLMap node — materialize it before property access.
|
|
103
|
+
const configNode = item.get('config') as unknown
|
|
104
|
+
const plain = (typeof configNode === 'object' && configNode !== null && typeof (configNode as { toJS?: unknown }).toJS === 'function'
|
|
105
|
+
? (configNode as { toJS(document: Document): unknown }).toJS(doc)
|
|
106
|
+
: {}) as Record<string, unknown>
|
|
107
|
+
return {
|
|
108
|
+
id: String(item.get('id') ?? ''),
|
|
109
|
+
serverName: String(plain.serverName ?? ''),
|
|
110
|
+
transport: plain.transport === 'streamable-http' ? 'streamable-http' : 'stdio',
|
|
111
|
+
disabled: item.get('disabled') === true,
|
|
112
|
+
...(typeof plain.command === 'string' && plain.command !== '' ? { command: plain.command } : {}),
|
|
113
|
+
...(Array.isArray(plain.args) ? { args: plain.args.map(String) } : {}),
|
|
114
|
+
...(isStringMap(plain.env) ? { env: plain.env } : {}),
|
|
115
|
+
...(typeof plain.cwd === 'string' && plain.cwd !== '' ? { cwd: plain.cwd } : {}),
|
|
116
|
+
...(typeof plain.url === 'string' && plain.url !== '' ? { url: plain.url } : {}),
|
|
117
|
+
...(isStringMap(plain.headers) ? { headers: plain.headers } : {}),
|
|
118
|
+
}
|
|
63
119
|
}
|
|
64
120
|
|
|
65
121
|
function isStringMap(value: unknown): value is Record<string, string> {
|
|
@@ -67,28 +123,52 @@ function isStringMap(value: unknown): value is Record<string, string> {
|
|
|
67
123
|
return Object.values(value).every(entry => typeof entry === 'string')
|
|
68
124
|
}
|
|
69
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The insert list that owns managed rows, creating it when absent and
|
|
128
|
+
* absorbing legacy bare rows into it. Absorbed rows were inert under the
|
|
129
|
+
* loader's override-only reading of bare entries, so the move is not just
|
|
130
|
+
* cosmetic — it is what makes them compose.
|
|
131
|
+
*/
|
|
132
|
+
function managedInsert(doc: Document): YAMLSeq<YAMLMap> {
|
|
133
|
+
const seq = rowSeq(doc)
|
|
134
|
+
const bare: YAMLMap[] = []
|
|
135
|
+
let target: YAMLSeq<YAMLMap> | undefined
|
|
136
|
+
for (const item of seq.items ?? []) {
|
|
137
|
+
if (item.get('name') === MCP_PLUGIN) bare.push(item)
|
|
138
|
+
const list = insertListOf(item)
|
|
139
|
+
if (list !== undefined && list.items.some(row => row.get('name') === MCP_PLUGIN)) target ??= list
|
|
140
|
+
}
|
|
141
|
+
if (target === undefined) {
|
|
142
|
+
const entry = toNode<YAMLMap>({ insert: [] })
|
|
143
|
+
seq.add(entry)
|
|
144
|
+
target = entry.get('insert') as YAMLSeq<YAMLMap>
|
|
145
|
+
target.flow = false
|
|
146
|
+
}
|
|
147
|
+
for (const row of bare) {
|
|
148
|
+
seq.items.splice(seq.items.indexOf(row), 1)
|
|
149
|
+
target.add(row)
|
|
150
|
+
}
|
|
151
|
+
return target
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Every id in use: top-level patch entries and rows inside insert lists. */
|
|
155
|
+
function takenIds(doc: Document): Set<string> {
|
|
156
|
+
const taken = new Set<string>()
|
|
157
|
+
for (const item of rowSeq(doc).items ?? []) {
|
|
158
|
+
const id = String(item.get('id') ?? '')
|
|
159
|
+
if (id !== '') taken.add(id)
|
|
160
|
+
for (const row of insertListOf(item)?.items ?? []) {
|
|
161
|
+
const rowId = String(row.get('id') ?? '')
|
|
162
|
+
if (rowId !== '') taken.add(rowId)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return taken
|
|
166
|
+
}
|
|
167
|
+
|
|
70
168
|
/** Read every mcp-client row in the profile layer. */
|
|
71
169
|
export function listMcp(profileDirPath: string): McpRow[] {
|
|
72
170
|
const doc = loadPatch(profileDirPath)
|
|
73
|
-
return
|
|
74
|
-
// config is a YAMLMap node — materialize it before property access.
|
|
75
|
-
const configNode = item.get('config') as unknown
|
|
76
|
-
const plain = (typeof configNode === 'object' && configNode !== null && typeof (configNode as { toJS?: unknown }).toJS === 'function'
|
|
77
|
-
? (configNode as { toJS(document: Document): unknown }).toJS(doc)
|
|
78
|
-
: {}) as Record<string, unknown>
|
|
79
|
-
return {
|
|
80
|
-
id: String(item.get('id') ?? ''),
|
|
81
|
-
serverName: String(plain.serverName ?? ''),
|
|
82
|
-
transport: plain.transport === 'streamable-http' ? 'streamable-http' : 'stdio',
|
|
83
|
-
disabled: item.get('disabled') === true,
|
|
84
|
-
...(typeof plain.command === 'string' && plain.command !== '' ? { command: plain.command } : {}),
|
|
85
|
-
...(Array.isArray(plain.args) ? { args: plain.args.map(String) } : {}),
|
|
86
|
-
...(isStringMap(plain.env) ? { env: plain.env } : {}),
|
|
87
|
-
...(typeof plain.cwd === 'string' && plain.cwd !== '' ? { cwd: plain.cwd } : {}),
|
|
88
|
-
...(typeof plain.url === 'string' && plain.url !== '' ? { url: plain.url } : {}),
|
|
89
|
-
...(isStringMap(plain.headers) ? { headers: plain.headers } : {}),
|
|
90
|
-
}
|
|
91
|
-
})
|
|
171
|
+
return mcpRowItems(doc).map(({ node }) => rowToMcp(doc, node))
|
|
92
172
|
}
|
|
93
173
|
|
|
94
174
|
/** Validate one write request; returns the rejection reason or null. */
|
|
@@ -109,17 +189,15 @@ export function validateMcpInput(input: McpInput): string | null {
|
|
|
109
189
|
export function upsertMcp(profileDirPath: string, input: McpInput): string {
|
|
110
190
|
const inputId = input.id ?? ''
|
|
111
191
|
const doc = loadPatch(profileDirPath)
|
|
112
|
-
const
|
|
192
|
+
const list = managedInsert(doc)
|
|
113
193
|
|
|
114
194
|
const existing = inputId !== ''
|
|
115
|
-
?
|
|
195
|
+
? mcpRowItems(doc).find(({ node }) => String(node.get('id') ?? '') === inputId)
|
|
116
196
|
: undefined
|
|
117
197
|
|
|
118
198
|
let id = inputId !== '' ? inputId : `mcp-${input.serverName}`
|
|
119
199
|
if (existing === undefined) {
|
|
120
|
-
const taken =
|
|
121
|
-
(seq.items ?? []).map(item => String(item.get('id') ?? '')).filter(id => id !== ''),
|
|
122
|
-
)
|
|
200
|
+
const taken = takenIds(doc)
|
|
123
201
|
let suffix = 2
|
|
124
202
|
while (taken.has(id)) id = `mcp-${input.serverName}-${suffix++}`
|
|
125
203
|
}
|
|
@@ -143,8 +221,14 @@ export function upsertMcp(profileDirPath: string, input: McpInput): string {
|
|
|
143
221
|
if (input.disabled === true) row.disabled = true
|
|
144
222
|
|
|
145
223
|
const node = toNode<YAMLMap>(row)
|
|
146
|
-
if (existing === undefined)
|
|
147
|
-
|
|
224
|
+
if (existing === undefined) {
|
|
225
|
+
list.add(node)
|
|
226
|
+
} else if (existing.list !== undefined) {
|
|
227
|
+
existing.list.items.splice(existing.list.items.indexOf(existing.node), 1, node)
|
|
228
|
+
} else {
|
|
229
|
+
// Bare rows were absorbed above; reaching here means a foreign-shaped row.
|
|
230
|
+
rowSeq(doc).items.splice(rowSeq(doc).items.indexOf(existing.node), 1, node)
|
|
231
|
+
}
|
|
148
232
|
|
|
149
233
|
savePatch(profileDirPath, doc)
|
|
150
234
|
return id
|
|
@@ -153,10 +237,11 @@ export function upsertMcp(profileDirPath: string, input: McpInput): string {
|
|
|
153
237
|
/** Flip one row's disabled flag (absent = enabled). Returns false when missing. */
|
|
154
238
|
export function setMcpDisabled(profileDirPath: string, id: string, disabled: boolean): boolean {
|
|
155
239
|
const doc = loadPatch(profileDirPath)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
|
|
240
|
+
managedInsert(doc)
|
|
241
|
+
const hit = mcpRowItems(doc).find(({ node }) => String(node.get('id') ?? '') === id)
|
|
242
|
+
if (hit === undefined) return false
|
|
243
|
+
if (disabled) hit.node.set('disabled', true)
|
|
244
|
+
else hit.node.delete('disabled')
|
|
160
245
|
savePatch(profileDirPath, doc)
|
|
161
246
|
return true
|
|
162
247
|
}
|
|
@@ -164,10 +249,19 @@ export function setMcpDisabled(profileDirPath: string, id: string, disabled: boo
|
|
|
164
249
|
/** Remove one server row. Returns false when missing. */
|
|
165
250
|
export function removeMcp(profileDirPath: string, id: string): boolean {
|
|
166
251
|
const doc = loadPatch(profileDirPath)
|
|
167
|
-
|
|
168
|
-
|
|
252
|
+
managedInsert(doc)
|
|
253
|
+
const hit = mcpRowItems(doc).find(({ node }) => String(node.get('id') ?? '') === id)
|
|
254
|
+
if (hit === undefined || hit.list === undefined) return false
|
|
255
|
+
hit.list.items.splice(hit.list.items.indexOf(hit.node), 1)
|
|
256
|
+
|
|
257
|
+
// An insert entry left with no rows is dead weight; drop it when the
|
|
258
|
+
// insert list is all it holds.
|
|
169
259
|
const seq = rowSeq(doc)
|
|
170
|
-
|
|
260
|
+
const owner = (seq.items ?? []).find(item => insertListOf(item) === hit.list)
|
|
261
|
+
if (owner !== undefined && hit.list.items.length === 0 && owner.items.length === 1) {
|
|
262
|
+
seq.items.splice(seq.items.indexOf(owner), 1)
|
|
263
|
+
}
|
|
264
|
+
|
|
171
265
|
savePatch(profileDirPath, doc)
|
|
172
266
|
return true
|
|
173
267
|
}
|