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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-capabilities",
3
- "version": "0.1.2",
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": ["deepseek", "harness", "dsh", "dsh-plugin", "skills", "mcp"],
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": ["lib", "src", "cordis.patch.yml", "LICENSE"],
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",
@@ -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
- <ul className="dpc-cards" style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}>
404
- {importItems.map((item, index) => (
405
- <li className="dpc-card" key={`${item.server.agent}/${item.server.name}`} style={{ opacity: item.existing ? 0.55 : 1 }}>
406
- <div className="dpc-cardTop">
407
- <label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0, cursor: item.existing ? 'default' : 'pointer' }}>
408
- <input
409
- type="checkbox"
410
- checked={item.checked}
411
- disabled={item.existing}
412
- onChange={(event) => {
413
- const next = importItems.slice()
414
- next[index] = { ...item, checked: event.target.checked }
415
- setImportItems(next)
416
- }}
417
- />
418
- <strong className="dpc-cardTitle" title={item.server.name}>{item.server.name}</strong>
419
- </label>
420
- <span className="dpc-tag" data-kind="source">{item.server.agent === 'claude-code' ? 'Claude Code' : 'Codex'}</span>
421
- <span className="dpc-tag">{item.server.transport}</span>
422
- {item.existing && <span className="dpc-tag">{t('importExisting')}</span>}
423
- </div>
424
- <p className="dpc-cardDesc">
425
- {item.server.transport === 'stdio'
426
- ? `${item.server.command ?? ''} ${(item.server.args ?? []).join(' ')}`
427
- : item.server.url ?? ''}
428
- </p>
429
- </li>
430
- ))}
431
- </ul>
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
  `
@@ -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
- return parseDocument(text)
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) doc.contents = toNode<YAMLSeq<YAMLMap>>([])
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
- /** Rows whose `name` is the MCP client plugin. */
61
- function mcpRows(doc: Document): YAMLMap[] {
62
- return (rowSeq(doc).items ?? []).filter(item => item.get('name') === MCP_PLUGIN)
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 mcpRows(doc).map(item => {
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 seq = rowSeq(doc)
192
+ const list = managedInsert(doc)
113
193
 
114
194
  const existing = inputId !== ''
115
- ? mcpRows(doc).find(item => item.get('id') === inputId)
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 = new Set(
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) seq.add(node)
147
- else seq.items[seq.items.indexOf(existing)] = node
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
- const item = mcpRows(doc).find(row => row.get('id') === id)
157
- if (item === undefined) return false
158
- if (disabled) item.set('disabled', true)
159
- else item.delete('disabled')
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
- const item = mcpRows(doc).find(row => row.get('id') === id)
168
- if (item === undefined) return false
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
- seq.items.splice(seq.items.indexOf(item), 1)
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
  }