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.
- package/README.md +21 -563
- package/bin/mcp-sse-bridge.mjs +499 -499
- package/docs/assets/conversation.png +0 -0
- package/docs/assets/cover.png +0 -0
- package/docs/assets/home.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
- package/docs/user-guide.md +616 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
- package/dsh-plugin.json +69 -69
- package/lib/boot-block.js +129 -0
- package/lib/diagnostics.js +162 -120
- package/lib/dsh-version.js +161 -0
- package/lib/git-branch.js +157 -157
- package/lib/i18n.js +283 -283
- package/lib/image.js +2 -1
- package/lib/index.js +5183 -4812
- package/lib/locales/ar.js +670 -635
- package/lib/locales/de.js +639 -604
- package/lib/locales/en.js +35 -0
- package/lib/locales/es.js +639 -604
- package/lib/locales/fr.js +639 -604
- package/lib/locales/id.js +641 -606
- package/lib/locales/it.js +639 -604
- package/lib/locales/ja.js +639 -604
- package/lib/locales/ko.js +639 -604
- package/lib/locales/nl.js +639 -604
- package/lib/locales/pl.js +653 -618
- package/lib/locales/pt-BR.js +639 -604
- package/lib/locales/ru.js +653 -618
- package/lib/locales/tr.js +639 -604
- package/lib/locales/vi.js +641 -606
- package/lib/locales/zh-CN.js +640 -605
- package/lib/locales/zh-TW.js +640 -605
- package/lib/markdown.js +101 -27
- package/lib/mcp-monitor.js +189 -0
- package/lib/mcp-skill.js +93 -93
- package/lib/mcp.js +865 -838
- package/lib/model-route.js +66 -0
- package/lib/providers.js +712 -712
- package/lib/screenshot.js +140 -0
- package/lib/splash-opencode.js +29 -26
- package/lib/startup.js +66 -48
- package/lib/term.js +53 -11
- package/lib/themes.js +101 -0
- package/lib/ui.js +685 -443
- package/lib/updates.js +5 -2
- package/lib/web-settings.js +52 -21
- package/package.json +160 -146
- 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
|
-
|
|
667
|
-
this
|
|
668
|
-
this.
|
|
669
|
-
this.
|
|
670
|
-
this.
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
this.
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
this.
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
try {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
+
}
|