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/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 id = upsertMcp(config.profileDirPath, input)
497
- sendJson(response, 200, { ok: true, id, restartNeeded: true })
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 })