dsh-oc-tui 0.2.0 → 0.3.0

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.
Files changed (50) hide show
  1. package/README.md +21 -563
  2. package/bin/mcp-sse-bridge.mjs +499 -499
  3. package/docs/assets/conversation.png +0 -0
  4. package/docs/assets/cover.png +0 -0
  5. package/docs/assets/home.png +0 -0
  6. package/docs/assets/settings.png +0 -0
  7. package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
  8. package/docs/user-guide.md +616 -0
  9. package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
  10. package/dsh-plugin.json +69 -69
  11. package/lib/boot-block.js +129 -0
  12. package/lib/diagnostics.js +162 -120
  13. package/lib/dsh-version.js +161 -0
  14. package/lib/git-branch.js +157 -157
  15. package/lib/i18n.js +283 -283
  16. package/lib/image.js +2 -1
  17. package/lib/index.js +5183 -4812
  18. package/lib/locales/ar.js +670 -635
  19. package/lib/locales/de.js +639 -604
  20. package/lib/locales/en.js +35 -0
  21. package/lib/locales/es.js +639 -604
  22. package/lib/locales/fr.js +639 -604
  23. package/lib/locales/id.js +641 -606
  24. package/lib/locales/it.js +639 -604
  25. package/lib/locales/ja.js +639 -604
  26. package/lib/locales/ko.js +639 -604
  27. package/lib/locales/nl.js +639 -604
  28. package/lib/locales/pl.js +653 -618
  29. package/lib/locales/pt-BR.js +639 -604
  30. package/lib/locales/ru.js +653 -618
  31. package/lib/locales/tr.js +639 -604
  32. package/lib/locales/vi.js +641 -606
  33. package/lib/locales/zh-CN.js +640 -605
  34. package/lib/locales/zh-TW.js +640 -605
  35. package/lib/markdown.js +101 -27
  36. package/lib/mcp-monitor.js +189 -0
  37. package/lib/mcp-skill.js +93 -93
  38. package/lib/mcp.js +865 -838
  39. package/lib/model-route.js +66 -0
  40. package/lib/providers.js +712 -712
  41. package/lib/screenshot.js +140 -0
  42. package/lib/splash-opencode.js +29 -26
  43. package/lib/startup.js +66 -48
  44. package/lib/term.js +53 -11
  45. package/lib/themes.js +101 -0
  46. package/lib/ui.js +685 -443
  47. package/lib/updates.js +5 -2
  48. package/lib/web-settings.js +52 -21
  49. package/package.json +160 -146
  50. package/skills/mcp-import/SKILL.md +216 -216
package/lib/mcp.js CHANGED
@@ -1,838 +1,865 @@
1
- // MCP server configuration for the TUI: the JSON documents that hold the server
2
- // list, the validation the form applies, and the live mounts that connect a
3
- // configured server to the open session's agent scope.
4
- //
5
- // Storage is two file scopes — `$DSH_HOME/mcp.json` for every session, and
6
- // `<dir>/.dsh/mcp.json` for a session whose cwd sits under `<dir>` — so this
7
- // plugin owns the documents themselves and never goes through the settings
8
- // service (on dsh 0.2.0 that service cannot describe a namespace the plugin did
9
- // not declare as its own Config field).
10
- //
11
- // Every read path degrades instead of throwing: a missing, unreadable or
12
- // malformed file costs that file's servers and nothing else, and the host's MCP
13
- // client, scope key and tool registry are optional too. Writes are the
14
- // exception — they run on a user action that must be able to show a reason, so
15
- // they throw.
16
- //
17
- // No @deepseek-ai module is imported at module scope: the plugin must install
18
- // and boot on a host that ships none, so both host modules load through a
19
- // dynamic import inside a try/catch.
20
-
21
- import { validateHeaderName, validateHeaderValue } from 'node:http'
22
- import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
23
- import { homedir } from 'node:os'
24
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
25
- import { fileURLToPath } from 'node:url'
26
- import { formatError } from './util.js'
27
- import { t } from './i18n.js'
28
-
29
- // The interface's server types. The host client only knows `stdio` and
30
- // `streamable-http`; `sse` is a UI type mounted through the bundled bridge.
31
- export const MCP_TYPES = ['stdio', 'http', 'sse']
32
- export const DEFAULT_TIMEOUT_MS = 30000
33
- export const MAX_TIMEOUT_MS = 3600000
34
- export const MCP_ENTRY_PREFIX = 'mcp__'
35
- export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
36
-
37
- // How long a single server may take to connect before its row is an error. The
38
- // host's own client has no connect budget of its own — it retries forever — so
39
- // without this a dead server would leave a `pending` row for the whole session.
40
- const CONNECT_TIMEOUT_MS = 20000
41
-
42
- // ---------------------------------------------------------------- storage ---
43
-
44
- // `$DSH_HOME` is the host's own root, so the user file lives beside the profiles
45
- // and settings the host writes; a host started without it falls back to ~/.dsh.
46
- export function dshHome() {
47
- const configured = String(process.env.DSH_HOME ?? '').trim()
48
- return configured ? resolve(configured) : join(homedir(), '.dsh')
49
- }
50
-
51
- export function userConfigPath(home = dshHome()) {
52
- const base = String(home ?? '').trim() || dshHome()
53
- return join(base, 'mcp.json')
54
- }
55
-
56
- export function pathConfigPath(directory) {
57
- return join(String(directory ?? ''), '.dsh', 'mcp.json')
58
- }
59
-
60
- // Every document that can define a server for `cwd`, nearest first and the user
61
- // file last. Nearest-first is what makes a closer file win a duplicate name.
62
- export function configPathsForCwd(cwd, home = dshHome()) {
63
- const locations = []
64
- const start = String(cwd ?? '').trim()
65
- if (start !== '') {
66
- let directory = resolve(start)
67
- for (;;) {
68
- locations.push({ scope: 'path', path: pathConfigPath(directory), directory })
69
- const parent = dirname(directory)
70
- if (parent === directory) break
71
- directory = parent
72
- }
73
- }
74
- locations.push({ scope: 'user', path: userConfigPath(home) })
75
- return locations
76
- }
77
-
78
- function normalizeTimeout(value) {
79
- const timeout = Number(String(value ?? '').trim())
80
- return Number.isInteger(timeout) && timeout >= 1 && timeout <= MAX_TIMEOUT_MS ? timeout : DEFAULT_TIMEOUT_MS
81
- }
82
-
83
- // A JSON value the document can hold as a string. Anything else is dropped: a
84
- // nested object stringified into "[object Object]" would reach a child process
85
- // environment as a lie.
86
- function normalizePairObject(value) {
87
- const pairs = {}
88
- if (!value || typeof value !== 'object' || Array.isArray(value)) return pairs
89
- for (const [name, raw] of Object.entries(value)) {
90
- if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') pairs[name] = String(raw)
91
- }
92
- return pairs
93
- }
94
-
95
- // One document record, normalized to the shape the document fixes. An in-memory
96
- // record always carries `name` — the document itself keeps it only as the map
97
- // key, and `serializeDocument` strips it again on the way out.
98
- function normalizeRecord(name, raw) {
99
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
100
- const declared = raw.type ?? raw.transport
101
- // `streamable-http` is the host's own name for this transport, and what an
102
- // earlier revision of this document stored; the interface type is `http`.
103
- const type = declared === 'streamable-http' ? 'http' : MCP_TYPES.includes(declared) ? declared : 'stdio'
104
- const record = {
105
- name,
106
- type,
107
- timeoutMs: normalizeTimeout(raw.timeoutMs),
108
- enabled: raw.enabled !== false,
109
- }
110
- if (type === 'stdio') {
111
- record.command = typeof raw.command === 'string' ? raw.command : ''
112
- record.args = Array.isArray(raw.args) ? raw.args.map((arg) => String(arg)) : []
113
- record.env = normalizePairObject(raw.env)
114
- } else {
115
- record.url = typeof raw.url === 'string' ? raw.url : ''
116
- record.headers = normalizePairObject(raw.headers)
117
- }
118
- return record
119
- }
120
-
121
- function recordName(record) {
122
- const name = record?.name ?? record?.serverName
123
- return typeof name === 'string' ? name : ''
124
- }
125
-
126
- function sortByName(servers) {
127
- return [...servers].sort((a, b) => recordName(a).localeCompare(recordName(b)))
128
- }
129
-
130
- // Tokenize a command line the way a shell would: whitespace splits, double
131
- // quotes group. An unclosed quote is an error rather than a silent truncation.
132
- // A JSON array is accepted as well, so an argument carrying whitespace or a
133
- // quote survives the single-line text field that has no way to express it.
134
- export function parseArgs(text) {
135
- const source = String(text ?? '').trim()
136
- if (source === '') return []
137
- if (source.startsWith('[')) {
138
- let parsed
139
- try {
140
- parsed = JSON.parse(source)
141
- } catch (error) {
142
- throw new Error('arguments are not a valid JSON array: ' + formatError(error))
143
- }
144
- if (!Array.isArray(parsed)) throw new Error(t('mcp.error.argsArray'))
145
- return parsed.map((value) => {
146
- if (value === null || typeof value === 'object') throw new Error(t('mcp.error.argsTypes'))
147
- return String(value)
148
- })
149
- }
150
- const args = []
151
- let current = ''
152
- let quoted = false
153
- let inToken = false
154
- for (const char of source) {
155
- if (char === '"') {
156
- quoted = !quoted
157
- inToken = true
158
- continue
159
- }
160
- if (!quoted && /\s/.test(char)) {
161
- if (inToken) args.push(current)
162
- current = ''
163
- inToken = false
164
- continue
165
- }
166
- current += char
167
- inToken = true
168
- }
169
- if (quoted) throw new Error(t('mcp.error.argsUnclosed'))
170
- if (inToken) args.push(current)
171
- return args
172
- }
173
-
174
- // A name carrying a control character is rejected before it can reach a child
175
- // process environment or an HTTP header line.
176
- function hasControlChar(text) {
177
- for (let index = 0; index < text.length; index++) {
178
- const code = text.charCodeAt(index)
179
- if (code < 0x20 || code === 0x7f) return true
180
- }
181
- return false
182
- }
183
-
184
- // The form's env/header field: a JSON object whose values are scalars. Empty
185
- // text is a legal empty map, so clearing the field clears the environment.
186
- export function parseKeyValueJson(text, { header = false, label = 'environment' } = {}) {
187
- const source = String(text ?? '').trim()
188
- if (source === '') return { ok: true, value: {} }
189
- let parsed
190
- try {
191
- parsed = JSON.parse(source)
192
- } catch (error) {
193
- // The catalog covers the sentence, not the parser detail: the label prefix
194
- // is translated and the thrown message is appended verbatim.
195
- return { ok: false, error: t('mcp.error.envObject', { label }) + ': ' + formatError(error) }
196
- }
197
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
198
- return { ok: false, error: t('mcp.error.envObject', { label }) }
199
- }
200
- const value = {}
201
- for (const [name, raw] of Object.entries(parsed)) {
202
- if (typeof raw !== 'string' && typeof raw !== 'number' && typeof raw !== 'boolean') {
203
- return { ok: false, error: t('mcp.error.envValue', { label, name }) }
204
- }
205
- if (header) {
206
- try {
207
- validateHeaderName(name)
208
- validateHeaderValue(name, String(raw))
209
- } catch {
210
- return { ok: false, error: t('mcp.error.header', { name }) }
211
- }
212
- } else if (name === '' || name.includes('=') || hasControlChar(name)) {
213
- return { ok: false, error: t('mcp.error.envName', { name }) }
214
- }
215
- value[name] = String(raw)
216
- }
217
- return { ok: true, value }
218
- }
219
-
220
- // One document's servers, or the reason it defines none. `mcpServers` is read
221
- // as an alias for `servers`: that is the key every other MCP client uses, so an
222
- // imported document works without editing.
223
- export function parseDocument(text) {
224
- let parsed
225
- try {
226
- parsed = JSON.parse(String(text ?? ''))
227
- } catch (error) {
228
- return { ok: false, error: t('mcp.error.json', { error: formatError(error) }) }
229
- }
230
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
231
- return { ok: false, error: t('mcp.error.jsonObject') }
232
- }
233
- const declared = parsed.servers !== undefined ? parsed.servers : parsed.mcpServers
234
- if (declared === undefined) return { ok: true, servers: [] }
235
- if (!declared || typeof declared !== 'object' || Array.isArray(declared)) {
236
- return { ok: false, error: t('mcp.error.serversObject') }
237
- }
238
- const servers = []
239
- for (const [name, raw] of Object.entries(declared)) {
240
- const record = normalizeRecord(name, raw)
241
- // A key whose value is not an object describes no server at all; the rest
242
- // of the file still reads.
243
- if (record) servers.push(record)
244
- }
245
- return { ok: true, servers: sortByName(servers) }
246
- }
247
-
248
- export async function readDocument(file) {
249
- let text
250
- try {
251
- text = await readFile(file, 'utf8')
252
- } catch (error) {
253
- if (error?.code === 'ENOENT') return { exists: false, servers: [], error: '' }
254
- return { exists: true, servers: [], error: formatError(error) }
255
- }
256
- const parsed = parseDocument(text)
257
- if (!parsed.ok) return { exists: true, servers: [], error: parsed.error }
258
- return { exists: true, servers: parsed.servers, error: '' }
259
- }
260
-
261
- // The effective server list for a session: every document from cwd up to the
262
- // filesystem root plus the user file, a duplicate name going to the nearest
263
- // file. `files` is every document that was considered, in that same order, so
264
- // the page can explain where a server came from — and which file failed.
265
- export async function collectServers(cwd, { home } = {}) {
266
- const locations = configPathsForCwd(cwd, home)
267
- const servers = []
268
- const files = []
269
- const claimed = new Set()
270
- for (const location of locations) {
271
- const document = await readDocument(location.path)
272
- // `directory` is the scope a path document stands for, so a page can label
273
- // the row without re-deriving it from the path; a user document has none.
274
- files.push({
275
- scope: location.scope,
276
- path: location.path,
277
- directory: location.directory ?? '',
278
- exists: document.exists,
279
- error: document.error,
280
- })
281
- for (const record of document.servers) {
282
- if (claimed.has(record.name)) continue
283
- claimed.add(record.name)
284
- servers.push({ ...record, scope: location.scope, sourcePath: location.path })
285
- }
286
- }
287
- return { servers: sortByName(servers), files }
288
- }
289
-
290
- // ---------------------------------------------------------------- write -----
291
-
292
- function storedRecord(entry) {
293
- const record = entry ?? {}
294
- const type = MCP_TYPES.includes(record.type) ? record.type : 'stdio'
295
- const common = { timeoutMs: normalizeTimeout(record.timeoutMs), enabled: record.enabled !== false }
296
- if (type === 'stdio') {
297
- return {
298
- type,
299
- command: String(record.command ?? ''),
300
- args: Array.isArray(record.args) ? record.args.map((arg) => String(arg)) : [],
301
- env: normalizePairObject(record.env),
302
- ...common,
303
- }
304
- }
305
- return {
306
- type,
307
- url: String(record.url ?? ''),
308
- headers: normalizePairObject(record.headers),
309
- ...common,
310
- }
311
- }
312
-
313
- // The document on disk: `servers` keyed by name, every decoration an in-memory
314
- // record picked up on the way (name, scope, sourcePath) left out.
315
- export function serializeDocument(servers) {
316
- const document = {}
317
- for (const entry of sortByName(Array.isArray(servers) ? servers : [])) {
318
- const name = recordName(entry)
319
- if (name === '') continue
320
- document[name] = storedRecord(entry)
321
- }
322
- return JSON.stringify({ version: 1, servers: document }, null, 2) + '\n'
323
- }
324
-
325
- // A temp file in the target directory plus a rename: an interrupted write
326
- // leaves the previous document readable instead of a truncated one. The rename
327
- // replaces an existing file on every platform this plugin runs on.
328
- async function writeDocument(file, servers) {
329
- const directory = dirname(file)
330
- await mkdir(directory, { recursive: true })
331
- const temporary = join(directory, '.' + basename(file) + '.' + process.pid + '.' + Date.now().toString(36) + '.tmp')
332
- try {
333
- await writeFile(temporary, serializeDocument(servers), 'utf8')
334
- await rename(temporary, file)
335
- } catch (error) {
336
- await rm(temporary, { force: true }).catch(() => undefined)
337
- throw error
338
- }
339
- }
340
-
341
- async function existingKind(path) {
342
- try {
343
- return await stat(path)
344
- } catch (error) {
345
- if (error?.code === 'ENOENT') return null
346
- throw error
347
- }
348
- }
349
-
350
- // -------------------------------------------------------------- validation ---
351
-
352
- export function emptyDraft() {
353
- return {
354
- name: '',
355
- type: 'stdio',
356
- timeoutMs: String(DEFAULT_TIMEOUT_MS),
357
- command: '',
358
- args: '',
359
- env: '',
360
- url: '',
361
- headers: '',
362
- scope: 'user',
363
- path: '',
364
- enabled: true,
365
- // The stored name while an existing server is edited: renaming is removing
366
- // one server and adding another, so the write needs to know what to drop.
367
- originalName: '',
368
- }
369
- }
370
-
371
- // A field that cannot hold whitespace, a quote or an empty argument is written
372
- // as a JSON array instead, which parseArgs reads back exactly.
373
- function formatArgs(args) {
374
- const plain = args.every((arg) => typeof arg === 'string' && arg !== '' && !/[\s"]/.test(arg))
375
- return plain ? args.join(' ') : JSON.stringify(args.map((arg) => String(arg)))
376
- }
377
-
378
- // The directory a path-scoped entry came from: the document sits in
379
- // `<directory>/.dsh/mcp.json`, so its own directory is two levels up.
380
- function directoryOf(record) {
381
- if (typeof record?.directory === 'string' && record.directory !== '') return record.directory
382
- const source = String(record?.sourcePath ?? '')
383
- return source === '' ? '' : dirname(dirname(source))
384
- }
385
-
386
- export function draftFromEntry(entry) {
387
- const record = entry ?? {}
388
- const scope = record.scope === 'path' ? 'path' : 'user'
389
- return {
390
- name: recordName(record),
391
- type: MCP_TYPES.includes(record.type) ? record.type : 'stdio',
392
- timeoutMs: String(record.timeoutMs ?? DEFAULT_TIMEOUT_MS),
393
- command: typeof record.command === 'string' ? record.command : '',
394
- args: formatArgs(Array.isArray(record.args) ? record.args.map((arg) => String(arg)) : []),
395
- env: JSON.stringify(record.env && typeof record.env === 'object' ? record.env : {}),
396
- url: typeof record.url === 'string' ? record.url : '',
397
- headers: JSON.stringify(record.headers && typeof record.headers === 'object' ? record.headers : {}),
398
- scope,
399
- path: scope === 'path' ? directoryOf(entry) : '',
400
- enabled: record.enabled !== false,
401
- originalName: recordName(entry),
402
- }
403
- }
404
-
405
- // Validate a form draft against the document contract. Returns the record to
406
- // store — carrying its name, the document key — or the first reason it cannot
407
- // work. `existing` is the list the name has to be unique in; the caller leaves
408
- // the edited server's own `originalName` out of it.
409
- export function validateDraft(draft, { existing = [] } = {}) {
410
- const source = draft ?? {}
411
- const name = String(source.name ?? '').trim()
412
- if (!SERVER_NAME_PATTERN.test(name)) {
413
- return { ok: false, error: t('mcp.error.name') }
414
- }
415
- if (existing.some((entry) => recordName(entry) === name)) {
416
- return { ok: false, error: t('mcp.error.duplicate', { name }) }
417
- }
418
-
419
- const declaredTimeout = String(source.timeoutMs ?? '').trim()
420
- let timeoutMs = DEFAULT_TIMEOUT_MS
421
- if (declaredTimeout !== '') {
422
- timeoutMs = Number(declaredTimeout)
423
- if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
424
- return { ok: false, error: t('mcp.error.timeout', { max: MAX_TIMEOUT_MS }) }
425
- }
426
- }
427
-
428
- if (source.scope === 'path') {
429
- const directory = String(source.path ?? '').trim()
430
- if (directory === '') return { ok: false, error: t('mcp.error.pathNeeded') }
431
- if (!isAbsolute(directory)) return { ok: false, error: t('mcp.error.pathAbsolute') }
432
- }
433
-
434
- const type = MCP_TYPES.includes(source.type) ? source.type : 'stdio'
435
- const record = { name, type, timeoutMs, enabled: source.enabled !== false }
436
- if (type === 'stdio') {
437
- const command = String(source.command ?? '').trim()
438
- if (command === '') return { ok: false, error: t('mcp.error.commandNeeded') }
439
- let args
440
- try {
441
- args = parseArgs(source.args)
442
- } catch (error) {
443
- return { ok: false, error: formatError(error) }
444
- }
445
- const env = parseKeyValueJson(source.env, { label: 'environment' })
446
- if (!env.ok) return { ok: false, error: env.error }
447
- return { ok: true, record: { ...record, command, args, env: env.value } }
448
- }
449
-
450
- const url = String(source.url ?? '').trim()
451
- let parsed
452
- try {
453
- parsed = new URL(url)
454
- } catch {
455
- return { ok: false, error: t('mcp.error.urlParse') }
456
- }
457
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
458
- return { ok: false, error: t('mcp.error.urlScheme') }
459
- }
460
- const headers = parseKeyValueJson(source.headers, { header: true, label: 'headers' })
461
- if (!headers.ok) return { ok: false, error: headers.error }
462
- return { ok: true, record: { ...record, url, headers: headers.value } }
463
- }
464
-
465
- // Replace or append one record, keyed by name, and keep the list sorted so the
466
- // page and the document do not reorder between a write and the next read.
467
- export function upsertEntry(servers, record, originalName = '') {
468
- const list = Array.isArray(servers) ? servers : []
469
- const dropped = new Set([originalName, recordName(record)].filter((name) => name !== ''))
470
- return sortByName([...list.filter((entry) => !dropped.has(recordName(entry))), record])
471
- }
472
-
473
- export function removeEntry(servers, name) {
474
- return (Array.isArray(servers) ? servers : []).filter((entry) => recordName(entry) !== name)
475
- }
476
-
477
- // Choose a draft's document, then read-modify-write it. Throws on a bad draft
478
- // or an I/O failure: the caller is a user action that must show the reason, and
479
- // a document that could not be parsed is never overwritten.
480
- export async function saveDraft({ cwd = '', draft, home = dshHome() } = {}) {
481
- const source = draft ?? {}
482
- const scope = source.scope === 'path' ? 'path' : 'user'
483
- let file
484
- if (scope === 'path') {
485
- const directory = String(source.path ?? '').trim()
486
- if (directory === '' || !isAbsolute(directory)) throw new Error(t('mcp.error.pathAbsolute'))
487
- const target = resolve(directory)
488
- const info = await existingKind(target)
489
- if (info && !info.isDirectory()) throw new Error(t('mcp.error.pathNotDir', { path: target }))
490
- file = pathConfigPath(target)
491
- } else {
492
- // `cwd` is part of the frozen signature but not of the choice: a user-scope
493
- // draft has one document, and a path-scope draft carries an absolute one.
494
- file = userConfigPath(home)
495
- }
496
- const fileInfo = await existingKind(file)
497
- if (fileInfo?.isDirectory()) throw new Error(t('mcp.error.configIsDir', { path: file }))
498
-
499
- const current = await readDocument(file)
500
- if (current.error !== '') throw new Error(current.error)
501
- const originalName = String(source.originalName ?? '')
502
- const existing = current.servers.filter((entry) => recordName(entry) !== originalName)
503
- const validated = validateDraft(source, { existing })
504
- if (!validated.ok) throw new Error(validated.error)
505
-
506
- const servers = upsertEntry(current.servers, validated.record, originalName)
507
- await writeDocument(file, servers)
508
- return { scope, path: file, servers }
509
- }
510
-
511
- // Delete a name from the document that defines it. A farther document may then
512
- // define the same name again — that is the scope contract, not a failure.
513
- // `sourcePath` names that document explicitly, which a scope move needs: once
514
- // the entry has been written to a nearer document, the effective entry is the
515
- // new one and the old file would no longer be found by name.
516
- export async function removeServer({ cwd = '', name, home = dshHome(), sourcePath } = {}) {
517
- const target = String(name ?? '').trim()
518
- let file = String(sourcePath ?? '').trim()
519
- if (file === '') {
520
- const collected = await collectServers(cwd, { home })
521
- const entry = collected.servers.find((server) => server.name === target)
522
- if (!entry) throw new Error(t('mcp.error.notFound', { name: target }))
523
- file = entry.sourcePath
524
- }
525
- const current = await readDocument(file)
526
- if (!current.exists) throw new Error(t('mcp.error.documentGone', { name: target, error: file }))
527
- if (current.error !== '') throw new Error(current.error)
528
- const servers = removeEntry(current.servers, target)
529
- if (servers.length === current.servers.length) throw new Error('no MCP server named "' + target + '" in ' + file)
530
-
531
- await writeDocument(file, servers)
532
- return { path: file, servers }
533
- }
534
-
535
- // ---------------------------------------------------------------- manager ---
536
-
537
- // The host client plugin, imported lazily: it is a harness dependency, but this
538
- // plugin must install and boot where it is absent.
539
- let mcpClientModule = null
540
- let mcpClientResolved = false
541
- async function loadMcpClient() {
542
- if (mcpClientResolved) return mcpClientModule
543
- mcpClientResolved = true
544
- try {
545
- mcpClientModule = await import('@deepseek-ai/dsh-mcp-client')
546
- } catch {
547
- mcpClientModule = null
548
- }
549
- return mcpClientModule
550
- }
551
-
552
- // The bundled bridge, resolved from this module's own package so an installed
553
- // or globally resolved copy both find it.
554
- let bridgePath = null
555
- function sseBridgePath() {
556
- if (bridgePath === null) bridgePath = fileURLToPath(new URL('../bin/mcp-sse-bridge.mjs', import.meta.url))
557
- return bridgePath
558
- }
559
-
560
- // The plugin config handed to one MCP client instance. Only fields the host's
561
- // schema declares are passed, and a stdio server gets the session's working
562
- // directory so a relative command resolves where the user works.
563
- function mountConfig(server, cwd) {
564
- const base = {
565
- serverName: recordName(server),
566
- failOnStartupError: true,
567
- toolCallTimeoutMs: normalizeTimeout(server?.timeoutMs),
568
- }
569
- if (server?.type === 'sse') {
570
- return {
571
- ...base,
572
- transport: 'stdio',
573
- command: process.execPath,
574
- args: [sseBridgePath()],
575
- // A URL and its headers can carry credentials, and a process's argv is
576
- // readable by every user on the machine: they travel in the environment.
577
- env: {
578
- MCP_SSE_URL: String(server.url ?? ''),
579
- MCP_SSE_HEADERS: JSON.stringify(server.headers ?? {}),
580
- MCP_SSE_TIMEOUT_MS: String(base.toolCallTimeoutMs),
581
- },
582
- cwd: cwd ?? '',
583
- }
584
- }
585
- if (server?.type === 'http') {
586
- return {
587
- ...base,
588
- transport: 'streamable-http',
589
- url: String(server.url ?? ''),
590
- headers: { ...(server.headers ?? {}) },
591
- }
592
- }
593
- return {
594
- ...base,
595
- transport: 'stdio',
596
- command: String(server?.command ?? ''),
597
- args: Array.isArray(server?.args) ? [...server.args] : [],
598
- env: { ...(server?.env ?? {}) },
599
- cwd: cwd ?? '',
600
- }
601
- }
602
-
603
- function serverTarget(server) {
604
- if (server?.type === 'http' || server?.type === 'sse') return String(server.url ?? '')
605
- const args = Array.isArray(server?.args) ? server.args.join(' ') : ''
606
- return args === '' ? String(server?.command ?? '') : String(server?.command ?? '') + ' ' + args
607
- }
608
-
609
- function disposeFiber(fiber) {
610
- return fiber?.dispose ? Promise.resolve(fiber.dispose()).catch(() => undefined) : Promise.resolve()
611
- }
612
-
613
- function withTimeout(thenable, ms, message) {
614
- return new Promise((resolve, reject) => {
615
- const timer = setTimeout(() => reject(new Error(message)), ms)
616
- Promise.resolve(thenable).then(
617
- () => { clearTimeout(timer); resolve() },
618
- (error) => { clearTimeout(timer); reject(error) },
619
- )
620
- })
621
- }
622
-
623
- // Read the tools one server registered on its agent scope. `tools.schemas`
624
- // answers what the agent sees (scoped registrations included), so a server with
625
- // no visible tool is a silent one. A host whose scope key or tool registry
626
- // cannot be read answers null, which the page shows as a bare "connected".
627
- let scopeResolver = null
628
- let scopeResolverTried = false
629
- async function loadScopeOf() {
630
- if (scopeResolverTried) return scopeResolver
631
- scopeResolverTried = true
632
- try {
633
- const mod = await import('@deepseek-ai/dsh-scope')
634
- scopeResolver = typeof mod?.scopeOf === 'function' ? mod.scopeOf : null
635
- } catch {
636
- scopeResolver = null
637
- }
638
- return scopeResolver
639
- }
640
-
641
- export async function createToolProbe() {
642
- const scopeOf = await loadScopeOf()
643
- if (!scopeOf) return () => null
644
- return (agentCtx, serverName) => {
645
- try {
646
- const tools = agentCtx?.get?.('tools') ?? agentCtx?.tools
647
- if (!tools || typeof tools.schemas !== 'function') return null
648
- const prefix = MCP_ENTRY_PREFIX + serverName + '__'
649
- return tools.schemas(scopeOf(agentCtx))
650
- .map((schema) => String(schema?.name ?? ''))
651
- .filter((name) => name.startsWith(prefix))
652
- .map((name) => name.slice(prefix.length))
653
- } catch {
654
- return null
655
- }
656
- }
657
- }
658
-
659
- // The live MCP mounts of the session that is currently open. One entry per
660
- // configured server, carrying the honest outcome of its connection attempt; a
661
- // configured server with no entry is simply not connected.
662
- export class McpManager {
663
- constructor({ probe, loadClient = loadMcpClient } = {}) {
664
- this.probe = probe ?? null
665
- this.loadClient = loadClient
666
- this.configured = []
667
- this.agentCtx = null
668
- this.attachGeneration = 0
669
- this.cwd = ''
670
- this.entries = new Map()
671
- }
672
-
673
- setConfigured(servers) {
674
- this.configured = Array.isArray(servers) ? servers : []
675
- }
676
-
677
- // Mount every enabled server on the given agent scope. Never awaited by the
678
- // caller: a slow server must not hold up the first frame of a session, and a
679
- // failing one must not stop the servers after it.
680
- attachAgent(agentCtx, cwd = '') {
681
- const generation = ++this.attachGeneration
682
- this.cwd = cwd
683
- void this.detachAgent().then(() => {
684
- // detachAgent clears the scope, so it is restored here — and a second
685
- // attach arriving meanwhile leaves this one to be superseded rather than
686
- // mounting into a scope that is already being torn down.
687
- if (generation !== this.attachGeneration) return
688
- this.agentCtx = agentCtx ?? null
689
- for (const server of this.configured) {
690
- if (server?.enabled === false) continue
691
- void this.mount(server)
692
- }
693
- })
694
- return this
695
- }
696
-
697
- async detachAgent() {
698
- this.agentCtx = null
699
- await this.removeAll()
700
- }
701
-
702
- // Drop every live mount but keep the configured list: a reconnect, not a
703
- // configuration change.
704
- async removeAll() {
705
- const entries = [...this.entries.values()]
706
- this.entries.clear()
707
- for (const entry of entries) {
708
- entry.cancelled = true
709
- await disposeFiber(entry.fiber)
710
- }
711
- }
712
-
713
- // Final teardown. The generation bump supersedes an attach still in flight,
714
- // so it cannot mount into the scope that was just cleared.
715
- async dispose() {
716
- this.attachGeneration++
717
- this.agentCtx = null
718
- this.configured = []
719
- await this.removeAll()
720
- }
721
-
722
- // What one mount is built from, so sync can tell a matching mount from a
723
- // reconfigured one without depending on record identity.
724
- mountKey(server) {
725
- try {
726
- return JSON.stringify(mountConfig(server, this.cwd))
727
- } catch {
728
- return ''
729
- }
730
- }
731
-
732
- // Install one server: connect, register its tools, record the outcome. A
733
- // failure is a row on the MCP page, never an exception into the session.
734
- async mount(server) {
735
- const name = recordName(server)
736
- if (name === '') return
737
- const agentCtx = this.agentCtx
738
- if (!agentCtx) return
739
- await this.unmount(name)
740
- const entry = { state: 'pending', error: '', tools: null, fiber: null, cancelled: false, key: this.mountKey(server) }
741
- this.entries.set(name, entry)
742
- const client = await this.loadClient()
743
- if (!client) {
744
- entry.state = 'error'
745
- entry.error = t('mcp.error.noClient')
746
- return
747
- }
748
- let fiber = null
749
- try {
750
- // Strict startup turns the plugin's own connection failure into a
751
- // rejection here, which is the only honest signal a failed server can
752
- // give; cordis disposes that plugin's fiber either way, so the agent
753
- // scope keeps running.
754
- fiber = agentCtx.plugin(client, mountConfig(server, this.cwd))
755
- entry.fiber = fiber
756
- await withTimeout(fiber, CONNECT_TIMEOUT_MS, t('mcp.error.connectTimeout', { name }))
757
- entry.state = 'connected'
758
- entry.error = ''
759
- } catch (error) {
760
- entry.state = 'error'
761
- entry.error = formatError(error)
762
- // A timed-out fiber is still connecting and would register its tools
763
- // after the row already says it failed; disposing it stops that.
764
- entry.fiber = null
765
- await disposeFiber(fiber)
766
- return
767
- }
768
- // A server dropped while it was connecting is disposed the moment its
769
- // fiber exists: its row is gone and its tools must not be registered.
770
- if (entry.cancelled) {
771
- entry.fiber = null
772
- await disposeFiber(fiber)
773
- return
774
- }
775
- entry.tools = this.toolsOf(name)
776
- }
777
-
778
- async unmount(name) {
779
- const entry = this.entries.get(name)
780
- if (!entry) return
781
- this.entries.delete(name)
782
- entry.cancelled = true
783
- await disposeFiber(entry.fiber)
784
- }
785
-
786
- // Reconcile the configured list with the live mounts: mount what is new or
787
- // reconfigured, unmount what went away, leave a matching mount alone.
788
- async sync() {
789
- const wanted = new Set()
790
- for (const server of this.configured) {
791
- if (server?.enabled === false) continue
792
- const name = recordName(server)
793
- if (name === '') continue
794
- wanted.add(name)
795
- const entry = this.entries.get(name)
796
- if (!entry || entry.key !== this.mountKey(server)) await this.mount(server)
797
- }
798
- for (const name of [...this.entries.keys()]) {
799
- if (!wanted.has(name)) await this.unmount(name)
800
- }
801
- this.refresh()
802
- }
803
-
804
- // Re-read each entry's tool list through the agent scope (a server can change
805
- // its tool list while connected, and a retry can succeed on its own).
806
- refresh() {
807
- for (const [name, entry] of this.entries) entry.tools = this.toolsOf(name)
808
- }
809
-
810
- toolsOf(name) {
811
- if (!this.probe) return null
812
- try {
813
- return this.probe(this.agentCtx, name) ?? null
814
- } catch {
815
- return null
816
- }
817
- }
818
-
819
- // What the MCP page renders: the configured list, each row carrying its live
820
- // state, where it came from, and the record it was built from.
821
- status() {
822
- return this.configured.map((server) => {
823
- const entry = this.entries.get(recordName(server))
824
- return {
825
- name: recordName(server),
826
- type: MCP_TYPES.includes(server?.type) ? server.type : 'stdio',
827
- target: serverTarget(server),
828
- enabled: server?.enabled !== false,
829
- scope: server?.scope ?? 'user',
830
- sourcePath: server?.sourcePath ?? '',
831
- state: server?.enabled === false ? 'disabled' : entry ? entry.state : 'unmounted',
832
- error: entry?.error ?? '',
833
- tools: entry?.tools ?? null,
834
- record: server,
835
- }
836
- })
837
- }
838
- }
1
+ // MCP server configuration for the TUI: the JSON documents that hold the server
2
+ // list, the validation the form applies, and the live mounts that connect a
3
+ // configured server to the open session's agent scope.
4
+ //
5
+ // Storage is two file scopes — `$DSH_HOME/mcp.json` for every session, and
6
+ // `<dir>/.dsh/mcp.json` for a session whose cwd sits under `<dir>` — so this
7
+ // plugin owns the documents themselves and never goes through the settings
8
+ // service (on dsh 0.2.0 that service cannot describe a namespace the plugin did
9
+ // not declare as its own Config field).
10
+ //
11
+ // Every read path degrades instead of throwing: a missing, unreadable or
12
+ // malformed file costs that file's servers and nothing else, and the host's MCP
13
+ // client, scope key and tool registry are optional too. Writes are the
14
+ // exception — they run on a user action that must be able to show a reason, so
15
+ // they throw.
16
+ //
17
+ // No @deepseek-ai module is imported at module scope: the plugin must install
18
+ // and boot on a host that ships none, so both host modules load through a
19
+ // dynamic import inside a try/catch.
20
+
21
+ import { validateHeaderName, validateHeaderValue } from 'node:http'
22
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
23
+ import { homedir } from 'node:os'
24
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { formatError } from './util.js'
27
+ import { t } from './i18n.js'
28
+
29
+ // The interface's server types. The host client only knows `stdio` and
30
+ // `streamable-http`; `sse` is a UI type mounted through the bundled bridge.
31
+ export const MCP_TYPES = ['stdio', 'http', 'sse']
32
+ export const DEFAULT_TIMEOUT_MS = 30000
33
+ export const MAX_TIMEOUT_MS = 3600000
34
+ export const MCP_ENTRY_PREFIX = 'mcp__'
35
+ export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
36
+
37
+ // How long a single server may take to connect before its row is an error. The
38
+ // host's own client has no connect budget of its own — it retries forever — so
39
+ // without this a dead server would leave a `pending` row for the whole session.
40
+ const CONNECT_TIMEOUT_MS = 20000
41
+
42
+ // ---------------------------------------------------------------- storage ---
43
+
44
+ // `$DSH_HOME` is the host's own root, so the user file lives beside the profiles
45
+ // and settings the host writes; a host started without it falls back to ~/.dsh.
46
+ export function dshHome() {
47
+ const configured = String(process.env.DSH_HOME ?? '').trim()
48
+ return configured ? resolve(configured) : join(homedir(), '.dsh')
49
+ }
50
+
51
+ export function userConfigPath(home = dshHome()) {
52
+ const base = String(home ?? '').trim() || dshHome()
53
+ return join(base, 'mcp.json')
54
+ }
55
+
56
+ export function pathConfigPath(directory) {
57
+ return join(String(directory ?? ''), '.dsh', 'mcp.json')
58
+ }
59
+
60
+ // Every document that can define a server for `cwd`, nearest first and the user
61
+ // file last. Nearest-first is what makes a closer file win a duplicate name.
62
+ export function configPathsForCwd(cwd, home = dshHome()) {
63
+ const locations = []
64
+ const start = String(cwd ?? '').trim()
65
+ if (start !== '') {
66
+ let directory = resolve(start)
67
+ for (;;) {
68
+ locations.push({ scope: 'path', path: pathConfigPath(directory), directory })
69
+ const parent = dirname(directory)
70
+ if (parent === directory) break
71
+ directory = parent
72
+ }
73
+ }
74
+ locations.push({ scope: 'user', path: userConfigPath(home) })
75
+ return locations
76
+ }
77
+
78
+ function normalizeTimeout(value) {
79
+ const timeout = Number(String(value ?? '').trim())
80
+ return Number.isInteger(timeout) && timeout >= 1 && timeout <= MAX_TIMEOUT_MS ? timeout : DEFAULT_TIMEOUT_MS
81
+ }
82
+
83
+ // A JSON value the document can hold as a string. Anything else is dropped: a
84
+ // nested object stringified into "[object Object]" would reach a child process
85
+ // environment as a lie.
86
+ function normalizePairObject(value) {
87
+ const pairs = {}
88
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return pairs
89
+ for (const [name, raw] of Object.entries(value)) {
90
+ if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') pairs[name] = String(raw)
91
+ }
92
+ return pairs
93
+ }
94
+
95
+ // One document record, normalized to the shape the document fixes. An in-memory
96
+ // record always carries `name` — the document itself keeps it only as the map
97
+ // key, and `serializeDocument` strips it again on the way out.
98
+ function normalizeRecord(name, raw) {
99
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
100
+ const declared = raw.type ?? raw.transport
101
+ // `streamable-http` is the host's own name for this transport, and what an
102
+ // earlier revision of this document stored; the interface type is `http`.
103
+ const type = declared === 'streamable-http' ? 'http' : MCP_TYPES.includes(declared) ? declared : 'stdio'
104
+ const record = {
105
+ name,
106
+ type,
107
+ timeoutMs: normalizeTimeout(raw.timeoutMs),
108
+ enabled: raw.enabled !== false,
109
+ }
110
+ if (type === 'stdio') {
111
+ record.command = typeof raw.command === 'string' ? raw.command : ''
112
+ record.args = Array.isArray(raw.args) ? raw.args.map((arg) => String(arg)) : []
113
+ record.env = normalizePairObject(raw.env)
114
+ } else {
115
+ record.url = typeof raw.url === 'string' ? raw.url : ''
116
+ record.headers = normalizePairObject(raw.headers)
117
+ }
118
+ return record
119
+ }
120
+
121
+ function recordName(record) {
122
+ const name = record?.name ?? record?.serverName
123
+ return typeof name === 'string' ? name : ''
124
+ }
125
+
126
+ function sortByName(servers) {
127
+ return [...servers].sort((a, b) => recordName(a).localeCompare(recordName(b)))
128
+ }
129
+
130
+ // Tokenize a command line the way a shell would: whitespace splits, double
131
+ // quotes group. An unclosed quote is an error rather than a silent truncation.
132
+ // A JSON array is accepted as well, so an argument carrying whitespace or a
133
+ // quote survives the single-line text field that has no way to express it.
134
+ export function parseArgs(text) {
135
+ const source = String(text ?? '').trim()
136
+ if (source === '') return []
137
+ if (source.startsWith('[')) {
138
+ let parsed
139
+ try {
140
+ parsed = JSON.parse(source)
141
+ } catch (error) {
142
+ throw new Error('arguments are not a valid JSON array: ' + formatError(error))
143
+ }
144
+ if (!Array.isArray(parsed)) throw new Error(t('mcp.error.argsArray'))
145
+ return parsed.map((value) => {
146
+ if (value === null || typeof value === 'object') throw new Error(t('mcp.error.argsTypes'))
147
+ return String(value)
148
+ })
149
+ }
150
+ const args = []
151
+ let current = ''
152
+ let quoted = false
153
+ let inToken = false
154
+ for (const char of source) {
155
+ if (char === '"') {
156
+ quoted = !quoted
157
+ inToken = true
158
+ continue
159
+ }
160
+ if (!quoted && /\s/.test(char)) {
161
+ if (inToken) args.push(current)
162
+ current = ''
163
+ inToken = false
164
+ continue
165
+ }
166
+ current += char
167
+ inToken = true
168
+ }
169
+ if (quoted) throw new Error(t('mcp.error.argsUnclosed'))
170
+ if (inToken) args.push(current)
171
+ return args
172
+ }
173
+
174
+ // A name carrying a control character is rejected before it can reach a child
175
+ // process environment or an HTTP header line.
176
+ function hasControlChar(text) {
177
+ for (let index = 0; index < text.length; index++) {
178
+ const code = text.charCodeAt(index)
179
+ if (code < 0x20 || code === 0x7f) return true
180
+ }
181
+ return false
182
+ }
183
+
184
+ // The form's env/header field: a JSON object whose values are scalars. Empty
185
+ // text is a legal empty map, so clearing the field clears the environment.
186
+ export function parseKeyValueJson(text, { header = false, label = 'environment' } = {}) {
187
+ const source = String(text ?? '').trim()
188
+ if (source === '') return { ok: true, value: {} }
189
+ let parsed
190
+ try {
191
+ parsed = JSON.parse(source)
192
+ } catch (error) {
193
+ // The catalog covers the sentence, not the parser detail: the label prefix
194
+ // is translated and the thrown message is appended verbatim.
195
+ return { ok: false, error: t('mcp.error.envObject', { label }) + ': ' + formatError(error) }
196
+ }
197
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
198
+ return { ok: false, error: t('mcp.error.envObject', { label }) }
199
+ }
200
+ const value = {}
201
+ for (const [name, raw] of Object.entries(parsed)) {
202
+ if (typeof raw !== 'string' && typeof raw !== 'number' && typeof raw !== 'boolean') {
203
+ return { ok: false, error: t('mcp.error.envValue', { label, name }) }
204
+ }
205
+ if (header) {
206
+ try {
207
+ validateHeaderName(name)
208
+ validateHeaderValue(name, String(raw))
209
+ } catch {
210
+ return { ok: false, error: t('mcp.error.header', { name }) }
211
+ }
212
+ } else if (name === '' || name.includes('=') || hasControlChar(name)) {
213
+ return { ok: false, error: t('mcp.error.envName', { name }) }
214
+ }
215
+ value[name] = String(raw)
216
+ }
217
+ return { ok: true, value }
218
+ }
219
+
220
+ // One document's servers, or the reason it defines none. `mcpServers` is read
221
+ // as an alias for `servers`: that is the key every other MCP client uses, so an
222
+ // imported document works without editing.
223
+ export function parseDocument(text) {
224
+ let parsed
225
+ try {
226
+ parsed = JSON.parse(String(text ?? ''))
227
+ } catch (error) {
228
+ return { ok: false, error: t('mcp.error.json', { error: formatError(error) }) }
229
+ }
230
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
231
+ return { ok: false, error: t('mcp.error.jsonObject') }
232
+ }
233
+ const declared = parsed.servers !== undefined ? parsed.servers : parsed.mcpServers
234
+ if (declared === undefined) return { ok: true, servers: [] }
235
+ if (!declared || typeof declared !== 'object' || Array.isArray(declared)) {
236
+ return { ok: false, error: t('mcp.error.serversObject') }
237
+ }
238
+ const servers = []
239
+ for (const [name, raw] of Object.entries(declared)) {
240
+ const record = normalizeRecord(name, raw)
241
+ // A key whose value is not an object describes no server at all; the rest
242
+ // of the file still reads.
243
+ if (record) servers.push(record)
244
+ }
245
+ return { ok: true, servers: sortByName(servers) }
246
+ }
247
+
248
+ export async function readDocument(file) {
249
+ let text
250
+ try {
251
+ text = await readFile(file, 'utf8')
252
+ } catch (error) {
253
+ if (error?.code === 'ENOENT') return { exists: false, servers: [], error: '' }
254
+ return { exists: true, servers: [], error: formatError(error) }
255
+ }
256
+ const parsed = parseDocument(text)
257
+ if (!parsed.ok) return { exists: true, servers: [], error: parsed.error }
258
+ return { exists: true, servers: parsed.servers, error: '' }
259
+ }
260
+
261
+ // The effective server list for a session: every document from cwd up to the
262
+ // filesystem root plus the user file, a duplicate name going to the nearest
263
+ // file. `files` is every document that was considered, in that same order, so
264
+ // the page can explain where a server came from — and which file failed.
265
+ export async function collectServers(cwd, { home } = {}) {
266
+ const locations = configPathsForCwd(cwd, home)
267
+ const servers = []
268
+ const files = []
269
+ const claimed = new Set()
270
+ for (const location of locations) {
271
+ const document = await readDocument(location.path)
272
+ // `directory` is the scope a path document stands for, so a page can label
273
+ // the row without re-deriving it from the path; a user document has none.
274
+ files.push({
275
+ scope: location.scope,
276
+ path: location.path,
277
+ directory: location.directory ?? '',
278
+ exists: document.exists,
279
+ error: document.error,
280
+ })
281
+ for (const record of document.servers) {
282
+ if (claimed.has(record.name)) continue
283
+ claimed.add(record.name)
284
+ servers.push({ ...record, scope: location.scope, sourcePath: location.path })
285
+ }
286
+ }
287
+ return { servers: sortByName(servers), files }
288
+ }
289
+
290
+ // ---------------------------------------------------------------- write -----
291
+
292
+ function storedRecord(entry) {
293
+ const record = entry ?? {}
294
+ const type = MCP_TYPES.includes(record.type) ? record.type : 'stdio'
295
+ const common = { timeoutMs: normalizeTimeout(record.timeoutMs), enabled: record.enabled !== false }
296
+ if (type === 'stdio') {
297
+ return {
298
+ type,
299
+ command: String(record.command ?? ''),
300
+ args: Array.isArray(record.args) ? record.args.map((arg) => String(arg)) : [],
301
+ env: normalizePairObject(record.env),
302
+ ...common,
303
+ }
304
+ }
305
+ return {
306
+ type,
307
+ url: String(record.url ?? ''),
308
+ headers: normalizePairObject(record.headers),
309
+ ...common,
310
+ }
311
+ }
312
+
313
+ // The document on disk: `servers` keyed by name, every decoration an in-memory
314
+ // record picked up on the way (name, scope, sourcePath) left out.
315
+ export function serializeDocument(servers) {
316
+ const document = {}
317
+ for (const entry of sortByName(Array.isArray(servers) ? servers : [])) {
318
+ const name = recordName(entry)
319
+ if (name === '') continue
320
+ document[name] = storedRecord(entry)
321
+ }
322
+ return JSON.stringify({ version: 1, servers: document }, null, 2) + '\n'
323
+ }
324
+
325
+ // A temp file in the target directory plus a rename: an interrupted write
326
+ // leaves the previous document readable instead of a truncated one. The rename
327
+ // replaces an existing file on every platform this plugin runs on.
328
+ async function writeDocument(file, servers) {
329
+ const directory = dirname(file)
330
+ await mkdir(directory, { recursive: true })
331
+ const temporary = join(directory, '.' + basename(file) + '.' + process.pid + '.' + Date.now().toString(36) + '.tmp')
332
+ try {
333
+ await writeFile(temporary, serializeDocument(servers), 'utf8')
334
+ await rename(temporary, file)
335
+ } catch (error) {
336
+ await rm(temporary, { force: true }).catch(() => undefined)
337
+ throw error
338
+ }
339
+ }
340
+
341
+ async function existingKind(path) {
342
+ try {
343
+ return await stat(path)
344
+ } catch (error) {
345
+ if (error?.code === 'ENOENT') return null
346
+ throw error
347
+ }
348
+ }
349
+
350
+ // -------------------------------------------------------------- validation ---
351
+
352
+ export function emptyDraft() {
353
+ return {
354
+ name: '',
355
+ type: 'stdio',
356
+ timeoutMs: String(DEFAULT_TIMEOUT_MS),
357
+ command: '',
358
+ args: '',
359
+ env: '',
360
+ url: '',
361
+ headers: '',
362
+ scope: 'user',
363
+ path: '',
364
+ enabled: true,
365
+ // The stored name while an existing server is edited: renaming is removing
366
+ // one server and adding another, so the write needs to know what to drop.
367
+ originalName: '',
368
+ }
369
+ }
370
+
371
+ // A field that cannot hold whitespace, a quote or an empty argument is written
372
+ // as a JSON array instead, which parseArgs reads back exactly.
373
+ function formatArgs(args) {
374
+ const plain = args.every((arg) => typeof arg === 'string' && arg !== '' && !/[\s"]/.test(arg))
375
+ return plain ? args.join(' ') : JSON.stringify(args.map((arg) => String(arg)))
376
+ }
377
+
378
+ // The directory a path-scoped entry came from: the document sits in
379
+ // `<directory>/.dsh/mcp.json`, so its own directory is two levels up.
380
+ function directoryOf(record) {
381
+ if (typeof record?.directory === 'string' && record.directory !== '') return record.directory
382
+ const source = String(record?.sourcePath ?? '')
383
+ return source === '' ? '' : dirname(dirname(source))
384
+ }
385
+
386
+ export function draftFromEntry(entry) {
387
+ const record = entry ?? {}
388
+ const scope = record.scope === 'path' ? 'path' : 'user'
389
+ return {
390
+ name: recordName(record),
391
+ type: MCP_TYPES.includes(record.type) ? record.type : 'stdio',
392
+ timeoutMs: String(record.timeoutMs ?? DEFAULT_TIMEOUT_MS),
393
+ command: typeof record.command === 'string' ? record.command : '',
394
+ args: formatArgs(Array.isArray(record.args) ? record.args.map((arg) => String(arg)) : []),
395
+ env: JSON.stringify(record.env && typeof record.env === 'object' ? record.env : {}),
396
+ url: typeof record.url === 'string' ? record.url : '',
397
+ headers: JSON.stringify(record.headers && typeof record.headers === 'object' ? record.headers : {}),
398
+ scope,
399
+ path: scope === 'path' ? directoryOf(entry) : '',
400
+ enabled: record.enabled !== false,
401
+ originalName: recordName(entry),
402
+ }
403
+ }
404
+
405
+ // Validate a form draft against the document contract. Returns the record to
406
+ // store — carrying its name, the document key — or the first reason it cannot
407
+ // work. `existing` is the list the name has to be unique in; the caller leaves
408
+ // the edited server's own `originalName` out of it.
409
+ export function validateDraft(draft, { existing = [] } = {}) {
410
+ const source = draft ?? {}
411
+ const name = String(source.name ?? '').trim()
412
+ if (!SERVER_NAME_PATTERN.test(name)) {
413
+ return { ok: false, error: t('mcp.error.name') }
414
+ }
415
+ if (existing.some((entry) => recordName(entry) === name)) {
416
+ return { ok: false, error: t('mcp.error.duplicate', { name }) }
417
+ }
418
+
419
+ const declaredTimeout = String(source.timeoutMs ?? '').trim()
420
+ let timeoutMs = DEFAULT_TIMEOUT_MS
421
+ if (declaredTimeout !== '') {
422
+ timeoutMs = Number(declaredTimeout)
423
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
424
+ return { ok: false, error: t('mcp.error.timeout', { max: MAX_TIMEOUT_MS }) }
425
+ }
426
+ }
427
+
428
+ if (source.scope === 'path') {
429
+ const directory = String(source.path ?? '').trim()
430
+ if (directory === '') return { ok: false, error: t('mcp.error.pathNeeded') }
431
+ if (!isAbsolute(directory)) return { ok: false, error: t('mcp.error.pathAbsolute') }
432
+ }
433
+
434
+ const type = MCP_TYPES.includes(source.type) ? source.type : 'stdio'
435
+ const record = { name, type, timeoutMs, enabled: source.enabled !== false }
436
+ if (type === 'stdio') {
437
+ const command = String(source.command ?? '').trim()
438
+ if (command === '') return { ok: false, error: t('mcp.error.commandNeeded') }
439
+ let args
440
+ try {
441
+ args = parseArgs(source.args)
442
+ } catch (error) {
443
+ return { ok: false, error: formatError(error) }
444
+ }
445
+ const env = parseKeyValueJson(source.env, { label: 'environment' })
446
+ if (!env.ok) return { ok: false, error: env.error }
447
+ return { ok: true, record: { ...record, command, args, env: env.value } }
448
+ }
449
+
450
+ const url = String(source.url ?? '').trim()
451
+ let parsed
452
+ try {
453
+ parsed = new URL(url)
454
+ } catch {
455
+ return { ok: false, error: t('mcp.error.urlParse') }
456
+ }
457
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
458
+ return { ok: false, error: t('mcp.error.urlScheme') }
459
+ }
460
+ const headers = parseKeyValueJson(source.headers, { header: true, label: 'headers' })
461
+ if (!headers.ok) return { ok: false, error: headers.error }
462
+ return { ok: true, record: { ...record, url, headers: headers.value } }
463
+ }
464
+
465
+ // Replace or append one record, keyed by name, and keep the list sorted so the
466
+ // page and the document do not reorder between a write and the next read.
467
+ export function upsertEntry(servers, record, originalName = '') {
468
+ const list = Array.isArray(servers) ? servers : []
469
+ const dropped = new Set([originalName, recordName(record)].filter((name) => name !== ''))
470
+ return sortByName([...list.filter((entry) => !dropped.has(recordName(entry))), record])
471
+ }
472
+
473
+ export function removeEntry(servers, name) {
474
+ return (Array.isArray(servers) ? servers : []).filter((entry) => recordName(entry) !== name)
475
+ }
476
+
477
+ // Choose a draft's document, then read-modify-write it. Throws on a bad draft
478
+ // or an I/O failure: the caller is a user action that must show the reason, and
479
+ // a document that could not be parsed is never overwritten.
480
+ export async function saveDraft({ cwd = '', draft, home = dshHome() } = {}) {
481
+ const source = draft ?? {}
482
+ const scope = source.scope === 'path' ? 'path' : 'user'
483
+ let file
484
+ if (scope === 'path') {
485
+ const directory = String(source.path ?? '').trim()
486
+ if (directory === '' || !isAbsolute(directory)) throw new Error(t('mcp.error.pathAbsolute'))
487
+ const target = resolve(directory)
488
+ const info = await existingKind(target)
489
+ if (info && !info.isDirectory()) throw new Error(t('mcp.error.pathNotDir', { path: target }))
490
+ file = pathConfigPath(target)
491
+ } else {
492
+ // `cwd` is part of the frozen signature but not of the choice: a user-scope
493
+ // draft has one document, and a path-scope draft carries an absolute one.
494
+ file = userConfigPath(home)
495
+ }
496
+ const fileInfo = await existingKind(file)
497
+ if (fileInfo?.isDirectory()) throw new Error(t('mcp.error.configIsDir', { path: file }))
498
+
499
+ const current = await readDocument(file)
500
+ if (current.error !== '') throw new Error(current.error)
501
+ const originalName = String(source.originalName ?? '')
502
+ const existing = current.servers.filter((entry) => recordName(entry) !== originalName)
503
+ const validated = validateDraft(source, { existing })
504
+ if (!validated.ok) throw new Error(validated.error)
505
+
506
+ const servers = upsertEntry(current.servers, validated.record, originalName)
507
+ await writeDocument(file, servers)
508
+ return { scope, path: file, servers }
509
+ }
510
+
511
+ // Delete a name from the document that defines it. A farther document may then
512
+ // define the same name again — that is the scope contract, not a failure.
513
+ // `sourcePath` names that document explicitly, which a scope move needs: once
514
+ // the entry has been written to a nearer document, the effective entry is the
515
+ // new one and the old file would no longer be found by name.
516
+ export async function removeServer({ cwd = '', name, home = dshHome(), sourcePath } = {}) {
517
+ const target = String(name ?? '').trim()
518
+ let file = String(sourcePath ?? '').trim()
519
+ if (file === '') {
520
+ const collected = await collectServers(cwd, { home })
521
+ const entry = collected.servers.find((server) => server.name === target)
522
+ if (!entry) throw new Error(t('mcp.error.notFound', { name: target }))
523
+ file = entry.sourcePath
524
+ }
525
+ const current = await readDocument(file)
526
+ if (!current.exists) throw new Error(t('mcp.error.documentGone', { name: target, error: file }))
527
+ if (current.error !== '') throw new Error(current.error)
528
+ const servers = removeEntry(current.servers, target)
529
+ if (servers.length === current.servers.length) throw new Error('no MCP server named "' + target + '" in ' + file)
530
+
531
+ await writeDocument(file, servers)
532
+ return { path: file, servers }
533
+ }
534
+
535
+ // ---------------------------------------------------------------- manager ---
536
+
537
+ // The host client plugin, imported lazily: it is a harness dependency, but this
538
+ // plugin must install and boot where it is absent.
539
+ let mcpClientModule = null
540
+ let mcpClientResolved = false
541
+ async function loadMcpClient() {
542
+ if (mcpClientResolved) return mcpClientModule
543
+ mcpClientResolved = true
544
+ try {
545
+ mcpClientModule = await import('@deepseek-ai/dsh-mcp-client')
546
+ } catch {
547
+ mcpClientModule = null
548
+ }
549
+ return mcpClientModule
550
+ }
551
+
552
+ // The bundled bridge, resolved from this module's own package so an installed
553
+ // or globally resolved copy both find it.
554
+ let bridgePath = null
555
+ function sseBridgePath() {
556
+ if (bridgePath === null) bridgePath = fileURLToPath(new URL('../bin/mcp-sse-bridge.mjs', import.meta.url))
557
+ return bridgePath
558
+ }
559
+
560
+ // The plugin config handed to one MCP client instance. Only fields the host's
561
+ // schema declares are passed, and a stdio server gets the session's working
562
+ // directory so a relative command resolves where the user works.
563
+ function mountConfig(server, cwd) {
564
+ const base = {
565
+ serverName: recordName(server),
566
+ failOnStartupError: true,
567
+ toolCallTimeoutMs: normalizeTimeout(server?.timeoutMs),
568
+ }
569
+ if (server?.type === 'sse') {
570
+ return {
571
+ ...base,
572
+ transport: 'stdio',
573
+ command: process.execPath,
574
+ args: [sseBridgePath()],
575
+ // A URL and its headers can carry credentials, and a process's argv is
576
+ // readable by every user on the machine: they travel in the environment.
577
+ env: {
578
+ MCP_SSE_URL: String(server.url ?? ''),
579
+ MCP_SSE_HEADERS: JSON.stringify(server.headers ?? {}),
580
+ MCP_SSE_TIMEOUT_MS: String(base.toolCallTimeoutMs),
581
+ },
582
+ cwd: cwd ?? '',
583
+ }
584
+ }
585
+ if (server?.type === 'http') {
586
+ return {
587
+ ...base,
588
+ transport: 'streamable-http',
589
+ url: String(server.url ?? ''),
590
+ headers: { ...(server.headers ?? {}) },
591
+ }
592
+ }
593
+ return {
594
+ ...base,
595
+ transport: 'stdio',
596
+ command: String(server?.command ?? ''),
597
+ args: Array.isArray(server?.args) ? [...server.args] : [],
598
+ env: { ...(server?.env ?? {}) },
599
+ cwd: cwd ?? '',
600
+ }
601
+ }
602
+
603
+ function serverTarget(server) {
604
+ if (server?.type === 'http' || server?.type === 'sse') return String(server.url ?? '')
605
+ const args = Array.isArray(server?.args) ? server.args.join(' ') : ''
606
+ return args === '' ? String(server?.command ?? '') : String(server?.command ?? '') + ' ' + args
607
+ }
608
+
609
+ function disposeFiber(fiber) {
610
+ return fiber?.dispose ? Promise.resolve(fiber.dispose()).catch(() => undefined) : Promise.resolve()
611
+ }
612
+
613
+ function withTimeout(thenable, ms, message) {
614
+ return new Promise((resolve, reject) => {
615
+ const timer = setTimeout(() => reject(new Error(message)), ms)
616
+ Promise.resolve(thenable).then(
617
+ () => { clearTimeout(timer); resolve() },
618
+ (error) => { clearTimeout(timer); reject(error) },
619
+ )
620
+ })
621
+ }
622
+
623
+ // Read the tools one server registered on its agent scope. `tools.schemas`
624
+ // answers what the agent sees (scoped registrations included), so a server with
625
+ // no visible tool is a silent one. A host whose scope key or tool registry
626
+ // cannot be read answers null, which the page shows as a bare "connected".
627
+ let scopeResolver = null
628
+ let scopeResolverTried = false
629
+ async function loadScopeOf() {
630
+ if (scopeResolverTried) return scopeResolver
631
+ scopeResolverTried = true
632
+ try {
633
+ const mod = await import('@deepseek-ai/dsh-scope')
634
+ scopeResolver = typeof mod?.scopeOf === 'function' ? mod.scopeOf : null
635
+ } catch {
636
+ scopeResolver = null
637
+ }
638
+ return scopeResolver
639
+ }
640
+
641
+ export async function createToolProbe() {
642
+ const scopeOf = await loadScopeOf()
643
+ if (!scopeOf) return () => null
644
+ return (agentCtx, serverName) => {
645
+ try {
646
+ const tools = agentCtx?.get?.('tools') ?? agentCtx?.tools
647
+ if (!tools || typeof tools.schemas !== 'function') return null
648
+ const prefix = MCP_ENTRY_PREFIX + serverName + '__'
649
+ return tools.schemas(scopeOf(agentCtx))
650
+ .map((schema) => String(schema?.name ?? ''))
651
+ .filter((name) => name.startsWith(prefix))
652
+ .map((name) => name.slice(prefix.length))
653
+ } catch {
654
+ return null
655
+ }
656
+ }
657
+ }
658
+
659
+ // The live MCP mounts of the session that is currently open. One entry per
660
+ // configured server, carrying the honest outcome of its connection attempt; a
661
+ // configured server with no entry is simply not connected.
662
+ export class McpManager {
663
+ constructor({ probe, loadClient = loadMcpClient, onState } = {}) {
664
+ this.probe = probe ?? null
665
+ this.loadClient = loadClient
666
+ // Reports how a mount ended, so the caller can surface it. The caller
667
+ // decides what a notice looks like; this manager only states the truth.
668
+ this.onState = typeof onState === 'function' ? onState : null
669
+ this.configured = []
670
+ this.agentCtx = null
671
+ this.attachGeneration = 0
672
+ this.cwd = ''
673
+ this.entries = new Map()
674
+ }
675
+
676
+ reportState(name, status) {
677
+ if (!this.onState) return
678
+ try { this.onState(name, status) } catch { /* a listener must not break a mount */ }
679
+ }
680
+
681
+ setConfigured(servers) {
682
+ this.configured = Array.isArray(servers) ? servers : []
683
+ }
684
+
685
+ // Mount every enabled server on the given agent scope. Never awaited by the
686
+ // caller: a slow server must not hold up the first frame of a session, and a
687
+ // failing one must not stop the servers after it.
688
+ attachAgent(agentCtx, cwd = '') {
689
+ const generation = ++this.attachGeneration
690
+ this.cwd = cwd
691
+ void this.detachAgent().then(() => {
692
+ // detachAgent clears the scope, so it is restored here — and a second
693
+ // attach arriving meanwhile leaves this one to be superseded rather than
694
+ // mounting into a scope that is already being torn down.
695
+ if (generation !== this.attachGeneration) return
696
+ this.agentCtx = agentCtx ?? null
697
+ void this.mountAll(generation)
698
+ })
699
+ return this
700
+ }
701
+
702
+ // Start the mounts one frame apart rather than all in the same tick.
703
+ //
704
+ // Each `mount` runs synchronously up to its first await: it spawns the
705
+ // server's process, imports the client module on the first call, and hands
706
+ // the plugin to cordis. Launching a whole batch in one tick put every one of
707
+ // those on the same frame, and the interface stopped answering keys until the
708
+ // batch had been handed to the runtime — the freeze a user sees as "the TUI
709
+ // stalls while MCP loads". A yield between two starts costs no wall time (the
710
+ // previous server is already awaiting its connection) and gives input and
711
+ // paint their turn.
712
+ async mountAll(generation) {
713
+ for (const server of this.configured) {
714
+ if (generation !== this.attachGeneration) return
715
+ if (server?.enabled === false) continue
716
+ void this.mount(server)
717
+ await new Promise((resolve) => setImmediate(resolve))
718
+ }
719
+ }
720
+
721
+ async detachAgent() {
722
+ this.agentCtx = null
723
+ await this.removeAll()
724
+ }
725
+
726
+ // Drop every live mount but keep the configured list: a reconnect, not a
727
+ // configuration change.
728
+ async removeAll() {
729
+ const entries = [...this.entries.values()]
730
+ this.entries.clear()
731
+ for (const entry of entries) {
732
+ entry.cancelled = true
733
+ await disposeFiber(entry.fiber)
734
+ }
735
+ }
736
+
737
+ // Final teardown. The generation bump supersedes an attach still in flight,
738
+ // so it cannot mount into the scope that was just cleared.
739
+ async dispose() {
740
+ this.attachGeneration++
741
+ this.agentCtx = null
742
+ this.configured = []
743
+ await this.removeAll()
744
+ }
745
+
746
+ // What one mount is built from, so sync can tell a matching mount from a
747
+ // reconfigured one without depending on record identity.
748
+ mountKey(server) {
749
+ try {
750
+ return JSON.stringify(mountConfig(server, this.cwd))
751
+ } catch {
752
+ return ''
753
+ }
754
+ }
755
+
756
+ // Install one server: connect, register its tools, record the outcome. A
757
+ // failure is a row on the MCP page, never an exception into the session.
758
+ async mount(server) {
759
+ const name = recordName(server)
760
+ if (name === '') return
761
+ const agentCtx = this.agentCtx
762
+ if (!agentCtx) return
763
+ await this.unmount(name)
764
+ const entry = { state: 'pending', error: '', tools: null, fiber: null, cancelled: false, key: this.mountKey(server) }
765
+ this.entries.set(name, entry)
766
+ const client = await this.loadClient()
767
+ if (!client) {
768
+ entry.state = 'error'
769
+ entry.error = t('mcp.error.noClient')
770
+ this.reportState(name, 'failed')
771
+ return
772
+ }
773
+ let fiber = null
774
+ try {
775
+ // Strict startup turns the plugin's own connection failure into a
776
+ // rejection here, which is the only honest signal a failed server can
777
+ // give; cordis disposes that plugin's fiber either way, so the agent
778
+ // scope keeps running.
779
+ fiber = agentCtx.plugin(client, mountConfig(server, this.cwd))
780
+ entry.fiber = fiber
781
+ await withTimeout(fiber, CONNECT_TIMEOUT_MS, t('mcp.error.connectTimeout', { name }))
782
+ entry.state = 'connected'
783
+ entry.error = ''
784
+ this.reportState(name, 'connected')
785
+ } catch (error) {
786
+ entry.state = 'error'
787
+ entry.error = formatError(error)
788
+ this.reportState(name, 'failed')
789
+ // A timed-out fiber is still connecting and would register its tools
790
+ // after the row already says it failed; disposing it stops that.
791
+ entry.fiber = null
792
+ await disposeFiber(fiber)
793
+ return
794
+ }
795
+ // A server dropped while it was connecting is disposed the moment its
796
+ // fiber exists: its row is gone and its tools must not be registered.
797
+ if (entry.cancelled) {
798
+ entry.fiber = null
799
+ await disposeFiber(fiber)
800
+ return
801
+ }
802
+ entry.tools = this.toolsOf(name)
803
+ }
804
+
805
+ async unmount(name) {
806
+ const entry = this.entries.get(name)
807
+ if (!entry) return
808
+ this.entries.delete(name)
809
+ entry.cancelled = true
810
+ await disposeFiber(entry.fiber)
811
+ }
812
+
813
+ // Reconcile the configured list with the live mounts: mount what is new or
814
+ // reconfigured, unmount what went away, leave a matching mount alone.
815
+ async sync() {
816
+ const wanted = new Set()
817
+ for (const server of this.configured) {
818
+ if (server?.enabled === false) continue
819
+ const name = recordName(server)
820
+ if (name === '') continue
821
+ wanted.add(name)
822
+ const entry = this.entries.get(name)
823
+ if (!entry || entry.key !== this.mountKey(server)) await this.mount(server)
824
+ }
825
+ for (const name of [...this.entries.keys()]) {
826
+ if (!wanted.has(name)) await this.unmount(name)
827
+ }
828
+ this.refresh()
829
+ }
830
+
831
+ // Re-read each entry's tool list through the agent scope (a server can change
832
+ // its tool list while connected, and a retry can succeed on its own).
833
+ refresh() {
834
+ for (const [name, entry] of this.entries) entry.tools = this.toolsOf(name)
835
+ }
836
+
837
+ toolsOf(name) {
838
+ if (!this.probe) return null
839
+ try {
840
+ return this.probe(this.agentCtx, name) ?? null
841
+ } catch {
842
+ return null
843
+ }
844
+ }
845
+
846
+ // What the MCP page renders: the configured list, each row carrying its live
847
+ // state, where it came from, and the record it was built from.
848
+ status() {
849
+ return this.configured.map((server) => {
850
+ const entry = this.entries.get(recordName(server))
851
+ return {
852
+ name: recordName(server),
853
+ type: MCP_TYPES.includes(server?.type) ? server.type : 'stdio',
854
+ target: serverTarget(server),
855
+ enabled: server?.enabled !== false,
856
+ scope: server?.scope ?? 'user',
857
+ sourcePath: server?.sourcePath ?? '',
858
+ state: server?.enabled === false ? 'disabled' : entry ? entry.state : 'unmounted',
859
+ error: entry?.error ?? '',
860
+ tools: entry?.tools ?? null,
861
+ record: server,
862
+ }
863
+ })
864
+ }
865
+ }