dsh-plugin-capabilities 0.3.8 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/lib/client.js +96 -13
- package/lib/client.js.map +2 -2
- package/lib/index.js +175 -8
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
- package/src/agents.test.ts +47 -1
- package/src/agents.ts +59 -8
- package/src/client/MarketTab.tsx +23 -7
- package/src/client/McpTab.tsx +69 -4
- package/src/client/index.ts +7 -4
- package/src/client/locales.ts +14 -4
- package/src/mcp.test.ts +70 -2
- package/src/mcp.ts +78 -0
- package/src/routes.ts +81 -6
package/src/mcp.ts
CHANGED
|
@@ -343,3 +343,81 @@ export function removeMcp(dirPath: string, id: string): boolean {
|
|
|
343
343
|
savePatch(dirPath, doc)
|
|
344
344
|
return true
|
|
345
345
|
}
|
|
346
|
+
|
|
347
|
+
/** Rebuild a create request from an existing row (id emptied; identity fields
|
|
348
|
+
* carried over) — used to copy a row into the other patch layer. */
|
|
349
|
+
export function mcpRowToInput(row: McpRow): McpInput {
|
|
350
|
+
return {
|
|
351
|
+
id: '',
|
|
352
|
+
serverName: row.serverName,
|
|
353
|
+
transport: row.transport,
|
|
354
|
+
disabled: row.disabled,
|
|
355
|
+
...(row.transport === 'stdio'
|
|
356
|
+
? {
|
|
357
|
+
command: row.command,
|
|
358
|
+
...(row.args !== undefined ? { args: row.args } : {}),
|
|
359
|
+
...(row.env !== undefined ? { env: row.env } : {}),
|
|
360
|
+
...(row.cwd !== undefined ? { cwd: row.cwd } : {}),
|
|
361
|
+
}
|
|
362
|
+
: {
|
|
363
|
+
url: row.url,
|
|
364
|
+
...(row.headers !== undefined ? { headers: row.headers } : {}),
|
|
365
|
+
}),
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Whether a stdio command would resolve at spawn time: a bare name is looked
|
|
371
|
+
* up in the PATH entries (with the Windows executable extensions), anything
|
|
372
|
+
* with a path separator must exist as given. Pure filesystem reads — no
|
|
373
|
+
* process is spawned, so no console-window or side-effect concerns.
|
|
374
|
+
*/
|
|
375
|
+
export function resolveCommandOnPath(command: string, pathEnv: string, platform: string = process.platform): boolean {
|
|
376
|
+
if (command.includes('/') || command.includes('\\')) return existsSync(command)
|
|
377
|
+
const exts = platform === 'win32' ? ['', '.com', '.exe', '.bat', '.cmd'] : ['']
|
|
378
|
+
const separator = platform === 'win32' ? ';' : ':'
|
|
379
|
+
for (const rawDir of pathEnv.split(separator)) {
|
|
380
|
+
const dir = rawDir.trim().replace(/^"|"$/g, '')
|
|
381
|
+
if (dir === '') continue
|
|
382
|
+
for (const ext of exts) {
|
|
383
|
+
if (existsSync(join(dir, command + ext))) return true
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return false
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Outcome of a connectivity check, as the browser renders it. */
|
|
390
|
+
export interface McpCheckResult {
|
|
391
|
+
ok: boolean
|
|
392
|
+
/** Short technical detail for the hint line ("HTTP 200", "not found on PATH"). */
|
|
393
|
+
detail?: string
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Probe one server row without starting it: stdio rows are validated against
|
|
398
|
+
* the PATH (a spawn would leave console windows and side effects behind),
|
|
399
|
+
* streamable-http rows get a short GET — any HTTP status proves reachability,
|
|
400
|
+
* since MCP endpoints legitimately answer 405 to plain GETs.
|
|
401
|
+
*/
|
|
402
|
+
export async function checkMcpRow(row: McpRow, options: { timeoutMs?: number; pathEnv?: string; platform?: string } = {}): Promise<McpCheckResult> {
|
|
403
|
+
const pathEnv = options.pathEnv ?? process.env.PATH ?? ''
|
|
404
|
+
if (row.transport === 'stdio') {
|
|
405
|
+
const command = row.command ?? ''
|
|
406
|
+
if (command === '') return { ok: false, detail: 'row has no command' }
|
|
407
|
+
return resolveCommandOnPath(command, pathEnv, options.platform)
|
|
408
|
+
? { ok: true, detail: command }
|
|
409
|
+
: { ok: false, detail: `${command} not found on PATH` }
|
|
410
|
+
}
|
|
411
|
+
if (row.url === undefined || !/^https?:\/\//.test(row.url)) return { ok: false, detail: 'row has no http url' }
|
|
412
|
+
try {
|
|
413
|
+
const response = await fetch(row.url, {
|
|
414
|
+
headers: { accept: 'application/json, text/event-stream' },
|
|
415
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 5000),
|
|
416
|
+
})
|
|
417
|
+
return { ok: true, detail: `HTTP ${response.status}` }
|
|
418
|
+
} catch (error) {
|
|
419
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
420
|
+
const cause = (error as { cause?: { code?: unknown } }).cause?.code
|
|
421
|
+
return { ok: false, detail: cause !== undefined ? String(cause) : message }
|
|
422
|
+
}
|
|
423
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { dshLaunch, restartOwnedByShell, scheduleRestart, trustedRestartRequest
|
|
|
12
12
|
import { deleteSkill, setSkillPolicy, updateSkillFile, userSkillsDir, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
|
|
13
13
|
import { findRootByUrl, loadState, pluginStateDir, removeSkillRoot, type SkillRootEntry } from './state.ts'
|
|
14
14
|
import { removeTree } from './rmtree.ts'
|
|
15
|
-
import { listMcpScoped, mcpScopeDir, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput, type McpScope } from './mcp.ts'
|
|
15
|
+
import { checkMcpRow, listMcp, listMcpScoped, mcpRowToInput, mcpScopeDir, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput, type McpScope } from './mcp.ts'
|
|
16
16
|
import type { CapabilitiesHost, HostSkill } from './types.ts'
|
|
17
17
|
|
|
18
18
|
/**
|
|
@@ -465,7 +465,7 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
|
|
|
465
465
|
return
|
|
466
466
|
}
|
|
467
467
|
try {
|
|
468
|
-
const body = (await readJsonBody(request)) as { id?: unknown }
|
|
468
|
+
const body = (await readJsonBody(request)) as { id?: unknown; scope?: unknown }
|
|
469
469
|
if (typeof body.id !== 'string') {
|
|
470
470
|
sendJson(response, 400, { error: 'id is required' })
|
|
471
471
|
return
|
|
@@ -493,8 +493,9 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
|
|
|
493
493
|
sendJson(response, 400, { error: invalid })
|
|
494
494
|
return
|
|
495
495
|
}
|
|
496
|
-
const
|
|
497
|
-
|
|
496
|
+
const scope = readScope(body.scope)
|
|
497
|
+
const id = upsertMcp(mcpScopeDir(scope, config.profileDirPath, config.dshHomePath), input)
|
|
498
|
+
sendJson(response, 200, { ok: true, id, scope, restartNeeded: true })
|
|
498
499
|
} catch (error) {
|
|
499
500
|
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
500
501
|
}
|
|
@@ -603,6 +604,80 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
|
|
|
603
604
|
}
|
|
604
605
|
},
|
|
605
606
|
}),
|
|
607
|
+
|
|
608
|
+
host.webServer.register({
|
|
609
|
+
kind: 'exact',
|
|
610
|
+
path: '/dsh-plugin-capabilities/mcp/check',
|
|
611
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
612
|
+
if (request.method !== 'POST') {
|
|
613
|
+
response.writeHead(405, { allow: 'POST' })
|
|
614
|
+
response.end()
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
if (!sameOrigin(request)) {
|
|
618
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
const body = (await readJsonBody(request)) as { id?: unknown; scope?: unknown }
|
|
623
|
+
if (typeof body.id !== 'string') {
|
|
624
|
+
sendJson(response, 400, { error: 'id is required' })
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
const dir = mcpScopeDir(readScope(body.scope), config.profileDirPath, config.dshHomePath)
|
|
628
|
+
const row = listMcp(dir).find(item => item.id === body.id)
|
|
629
|
+
if (row === undefined) {
|
|
630
|
+
sendJson(response, 404, { error: 'server row not found' })
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
// No host-level timeout override needed: the probe is bounded
|
|
634
|
+
// (5s) and side-effect-free (PATH reads or a plain GET).
|
|
635
|
+
sendJson(response, 200, await checkMcpRow(row))
|
|
636
|
+
} catch (error) {
|
|
637
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
}),
|
|
641
|
+
|
|
642
|
+
host.webServer.register({
|
|
643
|
+
kind: 'exact',
|
|
644
|
+
path: '/dsh-plugin-capabilities/mcp/copy',
|
|
645
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
646
|
+
if (request.method !== 'POST') {
|
|
647
|
+
response.writeHead(405, { allow: 'POST' })
|
|
648
|
+
response.end()
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
if (!sameOrigin(request)) {
|
|
652
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
try {
|
|
656
|
+
const body = (await readJsonBody(request)) as { id?: unknown; scope?: unknown; toScope?: unknown }
|
|
657
|
+
if (typeof body.id !== 'string') {
|
|
658
|
+
sendJson(response, 400, { error: 'id is required' })
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
const scope = readScope(body.scope)
|
|
662
|
+
const toScope: McpScope = readScope(body.toScope)
|
|
663
|
+
const sourceRow = listMcp(mcpScopeDir(scope, config.profileDirPath, config.dshHomePath)).find(item => item.id === body.id)
|
|
664
|
+
if (sourceRow === undefined) {
|
|
665
|
+
sendJson(response, 404, { error: 'server row not found' })
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
const input = mcpRowToInput(sourceRow)
|
|
669
|
+
const invalid = validateMcpInput(input)
|
|
670
|
+
if (invalid !== null) {
|
|
671
|
+
sendJson(response, 400, { error: invalid })
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
const id = upsertMcp(mcpScopeDir(toScope, config.profileDirPath, config.dshHomePath), input)
|
|
675
|
+
sendJson(response, 200, { ok: true, id, scope: toScope, restartNeeded: true })
|
|
676
|
+
} catch (error) {
|
|
677
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
}),
|
|
606
681
|
host.webServer.register({
|
|
607
682
|
kind: 'exact',
|
|
608
683
|
path: '/dsh-plugin-capabilities/import/scan',
|
|
@@ -639,7 +714,7 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
|
|
|
639
714
|
return
|
|
640
715
|
}
|
|
641
716
|
try {
|
|
642
|
-
const body = (await readJsonBody(request)) as { items?: unknown }
|
|
717
|
+
const body = (await readJsonBody(request)) as { items?: unknown; scope?: unknown }
|
|
643
718
|
const wanted = new Set(
|
|
644
719
|
(Array.isArray(body.items) ? body.items : [])
|
|
645
720
|
.filter((item): item is { agent: string; name: string } =>
|
|
@@ -666,7 +741,7 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
|
|
|
666
741
|
results.push({ name: server.name, ok: false, error: invalid })
|
|
667
742
|
continue
|
|
668
743
|
}
|
|
669
|
-
upsertMcp(config.profileDirPath, input)
|
|
744
|
+
upsertMcp(mcpScopeDir(readScope(body.scope), config.profileDirPath, config.dshHomePath), input)
|
|
670
745
|
results.push({ name: server.name, ok: true })
|
|
671
746
|
}
|
|
672
747
|
sendJson(response, 200, { ok: results.every(item => item.ok), results, restartNeeded: true })
|