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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-capabilities",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Manage skills and MCP servers from the Web UI Settings. 在设置页管理 dsh 的技能与 MCP 服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { afterAll, describe, expect, it } from 'vitest'
5
- import { agentSkillRoots, scanAllMcp, scanClaudeMcp, scanCodexMcp } from './agents.ts'
5
+ import { agentSkillRoots, scanAllMcp, scanClaudeMcp, scanCodexMcp, scanCursorMcp, scanGeminiMcp } from './agents.ts'
6
6
 
7
7
  const home = mkdtempSync(join(tmpdir(), 'dsh-caps-agents-'))
8
8
  afterAll(() => rmSync(home, { recursive: true, force: true }))
@@ -88,6 +88,52 @@ describe('scanCodexMcp', () => {
88
88
  })
89
89
  })
90
90
 
91
+ describe('scanCursorMcp', () => {
92
+ it('reads ~/.cursor/mcp.json (mcpServers, Claude-shaped; SSE skipped)', () => {
93
+ mkdirSync(join(home, '.cursor'), { recursive: true })
94
+ writeFileSync(join(home, '.cursor', 'mcp.json'), JSON.stringify({
95
+ mcpServers: {
96
+ context7: { command: 'npx', args: ['-y', '@upstash/context7-mcp'] },
97
+ 'web-remote': { type: 'http', url: 'https://example.com/mcp' },
98
+ 'legacy-sse': { type: 'sse', url: 'https://example.com/sse' },
99
+ },
100
+ }))
101
+ const servers = scanCursorMcp(home)
102
+ expect(servers).toHaveLength(2)
103
+ expect(servers.find(server => server.name === 'context7')).toMatchObject({ agent: 'cursor', transport: 'stdio', command: 'npx' })
104
+ expect(servers.find(server => server.name === 'web-remote')).toMatchObject({ agent: 'cursor', transport: 'streamable-http', url: 'https://example.com/mcp' })
105
+ expect(servers.find(server => server.name === 'legacy-sse')).toBeUndefined()
106
+ })
107
+ it('returns empty for missing or malformed files', () => {
108
+ expect(scanCursorMcp(join(home, 'empty'))).toEqual([])
109
+ writeFileSync(join(home, '.cursor', 'mcp.json'), '{ broken json')
110
+ expect(scanCursorMcp(home)).toEqual([])
111
+ })
112
+ })
113
+
114
+ describe('scanGeminiMcp', () => {
115
+ it('maps command entries and httpUrl, skips SSE url entries', () => {
116
+ mkdirSync(join(home, '.gemini'), { recursive: true })
117
+ writeFileSync(join(home, '.gemini', 'settings.json'), JSON.stringify({
118
+ mcpServers: {
119
+ context7: { command: 'npx', args: ['-y', '@upstash/context7-mcp'], env: { A: 'b' } },
120
+ remote: { httpUrl: 'https://example.com/mcp' },
121
+ sseOnly: { url: 'https://example.com/sse' },
122
+ },
123
+ }))
124
+ const servers = scanGeminiMcp(home)
125
+ expect(servers).toHaveLength(2)
126
+ expect(servers.find(server => server.name === 'context7')).toMatchObject({ agent: 'gemini', transport: 'stdio', command: 'npx' })
127
+ expect(servers.find(server => server.name === 'remote')).toMatchObject({ agent: 'gemini', transport: 'streamable-http', url: 'https://example.com/mcp' })
128
+ expect(servers.find(server => server.name === 'sseOnly')).toBeUndefined()
129
+ })
130
+ it('returns empty for missing or malformed files', () => {
131
+ expect(scanGeminiMcp(join(home, 'empty'))).toEqual([])
132
+ writeFileSync(join(home, '.gemini', 'settings.json'), '{ broken json')
133
+ expect(scanGeminiMcp(home)).toEqual([])
134
+ })
135
+ })
136
+
91
137
  describe('scanAllMcp + agentSkillRoots', () => {
92
138
  it('dedupes by (agent, name) and keeps both agents', () => {
93
139
  writeFileSync(join(home, '.claude.json'), JSON.stringify(CLAUDE_JSON))
package/src/agents.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Foreign-agent config readers: MCP servers from Claude Code (~/.claude.json,
3
- * ~/.claude/settings.json) and Codex (~/.codex/config.toml). Pure reads of
3
+ * ~/.claude/settings.json), Codex (~/.codex/config.toml), Cursor
4
+ * (~/.cursor/mcp.json) and Gemini CLI (~/.gemini/settings.json). Pure reads of
4
5
  * well-known paths; anything missing or malformed yields an empty list.
5
6
  */
6
7
 
@@ -12,7 +13,7 @@ import type { McpTransport } from './mcp.ts'
12
13
 
13
14
  /** One MCP server discovered in a foreign agent's config. */
14
15
  export interface ImportedServer {
15
- agent: 'claude-code' | 'codex'
16
+ agent: 'claude-code' | 'codex' | 'cursor' | 'gemini'
16
17
  name: string
17
18
  transport: McpTransport
18
19
  command?: string
@@ -38,15 +39,15 @@ function stringArray(value: unknown): string[] | undefined {
38
39
  return out.length > 0 ? out : undefined
39
40
  }
40
41
 
41
- /** Map one Claude mcpServers entry; returns null for unsupported shapes (sse). */
42
- function mapClaudeEntry(name: string, entry: unknown): ImportedServer | null {
42
+ /** Map one mcpServers entry (Claude / Cursor share the shape); null for unsupported shapes (sse). */
43
+ function mapMcpServersEntry(agent: ImportedServer['agent'], name: string, entry: unknown): ImportedServer | null {
43
44
  if (typeof entry !== 'object' || entry === null) return null
44
45
  const record = entry as Record<string, unknown>
45
46
  const type = typeof record.type === 'string' ? record.type : 'stdio'
46
47
  if (type === 'stdio' || (type === 'stdio' && record.command !== undefined)) {
47
48
  if (typeof record.command !== 'string' || record.command === '') return null
48
49
  return {
49
- agent: 'claude-code', name, transport: 'stdio',
50
+ agent, name, transport: 'stdio',
50
51
  command: record.command,
51
52
  args: stringArray(record.args),
52
53
  env: stringEntries(record.env),
@@ -55,7 +56,7 @@ function mapClaudeEntry(name: string, entry: unknown): ImportedServer | null {
55
56
  if (type === 'http' || type === 'streamable-http') {
56
57
  if (typeof record.url !== 'string' || record.url === '') return null
57
58
  return {
58
- agent: 'claude-code', name, transport: 'streamable-http',
59
+ agent, name, transport: 'streamable-http',
59
60
  url: record.url,
60
61
  headers: stringEntries(record.headers),
61
62
  }
@@ -80,12 +81,62 @@ export function scanClaudeMcp(home: string = homedir()): ImportedServer[] {
80
81
  }
81
82
  const out: ImportedServer[] = []
82
83
  for (const [name, entry] of Object.entries(merged)) {
83
- const mapped = mapClaudeEntry(name, entry)
84
+ const mapped = mapMcpServersEntry('claude-code', name, entry)
84
85
  if (mapped !== null) out.push(mapped)
85
86
  }
86
87
  return out
87
88
  }
88
89
 
90
+ /** MCP servers from Cursor's ~/.cursor/mcp.json (mcpServers, Claude-shaped). */
91
+ export function scanCursorMcp(home: string = homedir()): ImportedServer[] {
92
+ const file = join(home, '.cursor', 'mcp.json')
93
+ if (!existsSync(file)) return []
94
+ let parsed: { mcpServers?: unknown }
95
+ try {
96
+ parsed = JSON.parse(readFileSync(file, 'utf8')) as { mcpServers?: unknown }
97
+ } catch {
98
+ return []
99
+ }
100
+ if (typeof parsed.mcpServers !== 'object' || parsed.mcpServers === null) return []
101
+ const out: ImportedServer[] = []
102
+ for (const [name, entry] of Object.entries(parsed.mcpServers)) {
103
+ const mapped = mapMcpServersEntry('cursor', name, entry)
104
+ if (mapped !== null) out.push(mapped)
105
+ }
106
+ return out
107
+ }
108
+
109
+ /** MCP servers from Gemini CLI's ~/.gemini/settings.json (mcpServers; httpUrl
110
+ * keys map to streamable-http, plain `url` marks SSE and is skipped). */
111
+ export function scanGeminiMcp(home: string = homedir()): ImportedServer[] {
112
+ const file = join(home, '.gemini', 'settings.json')
113
+ if (!existsSync(file)) return []
114
+ let parsed: { mcpServers?: unknown }
115
+ try {
116
+ parsed = JSON.parse(readFileSync(file, 'utf8')) as { mcpServers?: unknown }
117
+ } catch {
118
+ return []
119
+ }
120
+ if (typeof parsed.mcpServers !== 'object' || parsed.mcpServers === null) return []
121
+ const out: ImportedServer[] = []
122
+ for (const [name, entry] of Object.entries(parsed.mcpServers)) {
123
+ if (typeof entry !== 'object' || entry === null) continue
124
+ const record = entry as Record<string, unknown>
125
+ if (typeof record.command === 'string' && record.command !== '') {
126
+ out.push({
127
+ agent: 'gemini', name, transport: 'stdio',
128
+ command: record.command,
129
+ args: stringArray(record.args),
130
+ env: stringEntries(record.env),
131
+ })
132
+ } else if (typeof record.httpUrl === 'string' && record.httpUrl !== '') {
133
+ out.push({ agent: 'gemini', name, transport: 'streamable-http', url: record.httpUrl })
134
+ }
135
+ // `url`-only entries are SSE endpoints, which dsh's client cannot speak.
136
+ }
137
+ return out
138
+ }
139
+
89
140
  /** MCP servers from Codex's config.toml ([mcp_servers.<name>] tables). */
90
141
  export function scanCodexMcp(home: string = homedir()): ImportedServer[] {
91
142
  const file = join(home, '.codex', 'config.toml')
@@ -119,7 +170,7 @@ export function scanCodexMcp(home: string = homedir()): ImportedServer[] {
119
170
  /** All foreign-agent MCP servers, deduplicated by (agent, name). */
120
171
  export function scanAllMcp(home: string = homedir()): ImportedServer[] {
121
172
  const seen = new Set<string>()
122
- return [...scanClaudeMcp(home), ...scanCodexMcp(home)]
173
+ return [...scanClaudeMcp(home), ...scanCodexMcp(home), ...scanCursorMcp(home), ...scanGeminiMcp(home)]
123
174
  .filter(server => {
124
175
  const key = `${server.agent}/${server.name}`
125
176
  if (seen.has(key)) return false
@@ -9,7 +9,7 @@ import type { ReactElement } from 'react'
9
9
  import { Button, IconRefreshOutline14, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
10
10
  import { CSS } from './css.ts'
11
11
  import type { MarketRepoView, MarketServerView, Translate } from './index.ts'
12
- import type { McpInjected } from './McpTab.tsx'
12
+ import type { McpInjected, McpScope } from './McpTab.tsx'
13
13
 
14
14
  /** One registered skill repository as the roots route reports it. */
15
15
  export interface RootRowView {
@@ -30,7 +30,7 @@ export interface MarketInjected {
30
30
  skillsIndex(): Promise<{ source: 'remote' | 'bundled'; repos: Array<MarketRepoView & { installedId: string | null }> }>
31
31
  mcpIndex(): Promise<{ source: 'remote' | 'bundled'; servers: Array<MarketServerView & { installed: boolean }> }>
32
32
  installSkillRepo(url: string): Promise<{ ok: boolean; root: RootRowView }>
33
- installMcp(id: string): Promise<{ ok: boolean; id: string }>
33
+ installMcp(id: string, scope: McpScope): Promise<{ ok: boolean; id: string }>
34
34
  removeRoot(id: string): Promise<{ ok: boolean }>
35
35
  }
36
36
 
@@ -46,8 +46,9 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
46
46
  const [servers, setServers] = useState<Array<MarketServerView & { installed: boolean }> | null>(null)
47
47
  const [source, setSource] = useState<'remote' | 'bundled'>('remote')
48
48
  const [busyId, setBusyId] = useState<string | null>(null)
49
+ const [mcpScope, setMcpScope] = useState<McpScope>('profile')
49
50
  const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
50
- const [confirmUninstall, setConfirmUninstall] = useState<{ kind: 'root' | 'mcp'; id: string; name: string } | null>(null)
51
+ const [confirmUninstall, setConfirmUninstall] = useState<{ kind: 'root'; id: string; name: string } | { kind: 'mcp'; id: string; name: string; scope: McpScope } | null>(null)
51
52
  const [detail, setDetail] = useState<{ kind: Segment; id: string } | null>(null)
52
53
  const [reload, setReload] = useState(0)
53
54
 
@@ -93,7 +94,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
93
94
  setBusyId(server.id)
94
95
  setOutcome(null)
95
96
  try {
96
- await market.installMcp(server.id)
97
+ await market.installMcp(server.id, mcpScope)
97
98
  setOutcome({ ok: true, text: t('restartNeeded') })
98
99
  refreshMcp()
99
100
  } catch (error) {
@@ -114,7 +115,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
114
115
  setOutcome({ ok: true, text: t('rootRemoved') })
115
116
  refreshSkills()
116
117
  } else {
117
- await mcp.remove(target.id, 'profile')
118
+ await mcp.remove(target.id, target.scope)
118
119
  setOutcome({ ok: true, text: t('restartNeeded') })
119
120
  refreshMcp()
120
121
  }
@@ -213,6 +214,21 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
213
214
  {segment === 'mcp' && (
214
215
  <>
215
216
  <p className="dpc-intro">{t('marketMcpIntro')}</p>
217
+ <div className="dpc-cardRow">
218
+ <label style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
219
+ <span>{t('scopeLabel')}</span>
220
+ <select
221
+ className="dpc-select"
222
+ style={{ width: 'auto' }}
223
+ value={mcpScope}
224
+ disabled={busyId !== null}
225
+ onChange={(event) => setMcpScope(event.target.value as McpScope)}
226
+ >
227
+ <option value="profile">{t('scopeProfile')}</option>
228
+ <option value="global">{t('scopeGlobal')}</option>
229
+ </select>
230
+ </label>
231
+ </div>
216
232
  {servers === null && <p className="dpc-empty">{t('loading')}</p>}
217
233
  {servers !== null && servers.length === 0 && <p className="dpc-empty">{t('marketEmpty')}</p>}
218
234
  {servers !== null && servers.length > 0 && (
@@ -237,7 +253,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
237
253
  <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={(event) => { event.stopPropagation(); void mcp.list().then(
238
254
  (body) => {
239
255
  const row = body.servers.find(item => item.serverName === server.id)
240
- if (row !== undefined) setConfirmUninstall({ kind: 'mcp', id: row.id, name: server.id })
256
+ if (row !== undefined) setConfirmUninstall({ kind: 'mcp', id: row.id, name: server.id, scope: row.scope })
241
257
  },
242
258
  (error: Error) => setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }),
243
259
  ) }}>
@@ -285,7 +301,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
285
301
  <Button variant="ghost" disabled={busyId !== null} onClick={() => void mcp.list().then(
286
302
  (body) => {
287
303
  const row = body.servers.find(item => item.serverName === detailServer.id)
288
- if (row !== undefined) { setConfirmUninstall({ kind: 'mcp', id: row.id, name: title(detailServer) }); setDetail(null) }
304
+ if (row !== undefined) { setConfirmUninstall({ kind: 'mcp', id: row.id, name: title(detailServer), scope: row.scope }); setDetail(null) }
289
305
  },
290
306
  (error: Error) => setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }),
291
307
  )}>
@@ -33,8 +33,10 @@ export interface McpInjected {
33
33
  save(input: Record<string, unknown>): Promise<{ ok: boolean; id: string }>
34
34
  toggle(id: string, disabled: boolean, scope: McpScope): Promise<{ ok: boolean }>
35
35
  remove(id: string, scope: McpScope): Promise<{ ok: boolean }>
36
+ check(id: string, scope: McpScope): Promise<{ ok: boolean; detail?: string }>
37
+ copy(id: string, scope: McpScope, toScope: McpScope): Promise<{ ok: boolean; id: string }>
36
38
  scanImport(): Promise<{ servers: ImportedServerView[]; existing: string[] }>
37
- applyImport(items: Array<{ agent: string; name: string }>): Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>
39
+ applyImport(items: Array<{ agent: string; name: string }>, scope: McpScope): Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>
38
40
  restart(): Promise<void>
39
41
  desktop: boolean
40
42
  }
@@ -59,8 +61,13 @@ type ImportItem = { server: ImportedServerView; existing: boolean; checked: bool
59
61
 
60
62
  /** Group import candidates by source agent, known agents first. */
61
63
  export function importGroups(items: ImportItem[]): Array<{ agent: string; label: string; items: Array<{ item: ImportItem; index: number }> }> {
62
- const label = (agent: string) => agent === 'claude-code' ? 'Claude Code' : agent === 'codex' ? 'Codex' : agent
63
- const order = ['claude-code', 'codex']
64
+ const label = (agent: string) =>
65
+ agent === 'claude-code' ? 'Claude Code'
66
+ : agent === 'codex' ? 'Codex'
67
+ : agent === 'cursor' ? 'Cursor'
68
+ : agent === 'gemini' ? 'Gemini CLI'
69
+ : agent
70
+ const order = ['claude-code', 'codex', 'cursor', 'gemini']
64
71
  const agents = [...new Set(items.map(item => item.server.agent))]
65
72
  .sort((a, b) => {
66
73
  const rank = (agent: string) => { const at = order.indexOf(agent); return at === -1 ? order.length : at }
@@ -230,6 +237,9 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
230
237
  const [reload, setReload] = useState(0)
231
238
  const [pasteJson, setPasteJson] = useState('')
232
239
  const [pasteError, setPasteError] = useState<string | null>(null)
240
+ /** Connectivity probes keyed by `${scope}/${id}`; absent = never checked. */
241
+ const [checks, setChecks] = useState<Record<string, { ok: boolean; detail?: string } | 'busy'>>({})
242
+ const [importScope, setImportScope] = useState<McpScope>('profile')
233
243
 
234
244
  const openImport = async (): Promise<void> => {
235
245
  setImportOpen(true)
@@ -253,7 +263,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
253
263
  const items = importItems.filter(item => item.checked && !item.existing).map(item => ({ agent: item.server.agent, name: item.server.name }))
254
264
  setBusy(true)
255
265
  try {
256
- const body = await injected.applyImport(items)
266
+ const body = await injected.applyImport(items, importScope)
257
267
  const failed = body.results.filter(item => !item.ok)
258
268
  setOutcome(failed.length === 0
259
269
  ? { ok: true, text: t('restartNeeded') }
@@ -403,6 +413,33 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
403
413
  }
404
414
  }
405
415
 
416
+ /** Probe one row (PATH lookup for stdio, short GET for http); result kept per row. */
417
+ const doCheck = async (row: McpRow): Promise<void> => {
418
+ const key = `${row.scope}/${row.id}`
419
+ setChecks(current => ({ ...current, [key]: 'busy' }))
420
+ try {
421
+ const result = await injected.check(row.id, row.scope)
422
+ setChecks(current => ({ ...current, [key]: result }))
423
+ } catch (error) {
424
+ setChecks(current => ({ ...current, [key]: { ok: false, detail: String(error instanceof Error ? error.message : error) } }))
425
+ }
426
+ }
427
+
428
+ /** Duplicate the row into the other patch layer (ids dedupe server-side). */
429
+ const doCopy = async (row: McpRow): Promise<void> => {
430
+ setBusy(true)
431
+ try {
432
+ const toScope: McpScope = row.scope === 'global' ? 'profile' : 'global'
433
+ await injected.copy(row.id, row.scope, toScope)
434
+ setOutcome({ ok: true, text: t('restartNeeded') })
435
+ reloadList(true)
436
+ } catch (error) {
437
+ setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
438
+ } finally {
439
+ setBusy(false)
440
+ }
441
+ }
442
+
406
443
  const doRestart = (): void => {
407
444
  setRestartConfirm(false)
408
445
  setRestarting(true)
@@ -494,8 +531,21 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
494
531
  {row.transport === 'stdio' ? `${row.command ?? ''} ${(row.args ?? []).join(' ')}` : row.url ?? ''}
495
532
  </p>
496
533
  {row.shadowed === true && <p className="dpc-formError">{t('shadowedByGlobal')}</p>}
534
+ {(() => {
535
+ const check = checks[`${row.scope}/${row.id}`]
536
+ if (check === undefined) return null
537
+ if (check === 'busy') return <p className="dpc-cardDesc">{t('checkRunning')}</p>
538
+ return (
539
+ <p className={check.ok ? 'dpc-cardDesc' : 'dpc-formError'}>
540
+ {check.ok ? `✓ ${t('checkOk')}` : `✗ ${t('checkFail')}`}
541
+ {check.detail !== undefined && ` · ${check.detail}`}
542
+ </p>
543
+ )
544
+ })()}
497
545
  <div className="dpc-cardRow">
498
546
  <span className="dpc-spacer" />
547
+ <Button variant="ghost" size="sm" disabled={busy} onClick={() => void doCheck(row)}>{t('checkLabel')}</Button>
548
+ <Button variant="ghost" size="sm" disabled={busy} onClick={() => void doCopy(row)}>{t('copyToOther')}</Button>
499
549
  <Button variant="ghost" size="sm" disabled={busy} onClick={() => void doToggle(row)}>{t('toggle')}</Button>
500
550
  <Button variant="ghost" size="sm" disabled={busy} onClick={() => openEdit(row)}>{t('edit')}</Button>
501
551
  <Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmRow(row)}>{t('delete')}</Button>
@@ -658,6 +708,21 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
658
708
  >
659
709
  <div className="dpc-form">
660
710
  <p className="dpc-intro" style={{ margin: 0 }}>{t('importIntro')}</p>
711
+ <div className="dpc-cardRow">
712
+ <label style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
713
+ <span>{t('scopeLabel')}</span>
714
+ <select
715
+ className="dpc-select"
716
+ style={{ width: 'auto' }}
717
+ value={importScope}
718
+ disabled={busy}
719
+ onChange={(event) => setImportScope(event.target.value as McpScope)}
720
+ >
721
+ <option value="profile">{t('scopeProfile')}</option>
722
+ <option value="global">{t('scopeGlobal')}</option>
723
+ </select>
724
+ </label>
725
+ </div>
661
726
  {importItems === null && <p className="dpc-empty">{t('loading')}</p>}
662
727
  {importItems !== null && importItems.length === 0 && <p className="dpc-empty">{t('importEmpty')}</p>}
663
728
  {importItems !== null && importItems.length > 0 && (
@@ -94,9 +94,12 @@ export function apply(ctx: CapabilitiesClientContext): void {
94
94
  toggle: (id: string, disabled: boolean, scope: McpScope) =>
95
95
  post('/dsh-plugin-capabilities/mcp/toggle', { id, disabled, scope }) as Promise<{ ok: boolean }>,
96
96
  remove: (id: string, scope: McpScope) => post('/dsh-plugin-capabilities/mcp/remove', { id, scope }) as Promise<{ ok: boolean }>,
97
+ check: (id: string, scope: McpScope) => post('/dsh-plugin-capabilities/mcp/check', { id, scope }) as Promise<{ ok: boolean; detail?: string }>,
98
+ copy: (id: string, scope: McpScope, toScope: McpScope) =>
99
+ post('/dsh-plugin-capabilities/mcp/copy', { id, scope, toScope }) as Promise<{ ok: boolean; id: string }>,
97
100
  scanImport: () => fetchJson<{ servers: ImportedServerView[]; existing: string[] }>('/dsh-plugin-capabilities/import/scan'),
98
- applyImport: (items: Array<{ agent: string; name: string }>) =>
99
- post('/dsh-plugin-capabilities/import/apply', { items }) as Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>,
101
+ applyImport: (items: Array<{ agent: string; name: string }>, scope: McpScope) =>
102
+ post('/dsh-plugin-capabilities/import/apply', { items, scope }) as Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>,
100
103
  restart: async (): Promise<void> => {
101
104
  if (window.dshDesktop !== undefined) {
102
105
  window.dshDesktop.restartSidecar?.()
@@ -113,8 +116,8 @@ export function apply(ctx: CapabilitiesClientContext): void {
113
116
  mcpIndex: () => fetchJson<{ source: 'remote' | 'bundled'; servers: Array<MarketServerView & { installed: boolean }> }>('/dsh-plugin-capabilities/market/mcp'),
114
117
  installSkillRepo: (url: string) =>
115
118
  post('/dsh-plugin-capabilities/market/skills/install', { url }) as Promise<{ ok: boolean; root: RootRowView }>,
116
- installMcp: (id: string) =>
117
- post('/dsh-plugin-capabilities/market/mcp/install', { id }) as Promise<{ ok: boolean; id: string }>,
119
+ installMcp: (id: string, scope: McpScope) =>
120
+ post('/dsh-plugin-capabilities/market/mcp/install', { id, scope }) as Promise<{ ok: boolean; id: string }>,
118
121
  removeRoot: skillsInjected.removeRoot,
119
122
  }
120
123
 
@@ -77,6 +77,11 @@ export const zh = {
77
77
  scopeHint: '全局:写入 DSH_HOME/cordis.patch.yml,对本机所有 profile 生效;当前 profile:只影响本 profile。',
78
78
  shadowedByGlobal: '全局层存在同名 id,此行不生效',
79
79
  globalLayerError: '全局配置(DSH_HOME/cordis.patch.yml)读取失败,全局服务器暂未显示:',
80
+ checkLabel: '检查',
81
+ checkRunning: '检查中…',
82
+ checkOk: '检查通过',
83
+ checkFail: '检查未通过',
84
+ copyToOther: '复制到另一层',
80
85
  serverName: '服务器名(工具名前缀)',
81
86
  transport: '传输方式',
82
87
  transportStdio: 'stdio(本地命令)',
@@ -93,7 +98,7 @@ export const zh = {
93
98
  removeWarn: '将从配置中移除这一行,重启 dsh 后其工具不再出现。',
94
99
  emptyMcp: '还没有配置 MCP 服务器',
95
100
  importServers: '从其他 Agent 导入',
96
- importIntro: '扫描 Claude Code(~/.claude.json)与 Codex(~/.codex/config.toml)的 MCP 服务器配置,勾选后导入为本 profile 的服务器行。',
101
+ importIntro: '扫描 Claude Code(~/.claude.json)、Codex(~/.codex/config.toml)、Cursor(~/.cursor/mcp.json)与 Gemini CLI(~/.gemini/settings.json)的 MCP 服务器配置,勾选后导入到所选范围。',
97
102
  importEmpty: '没有发现可导入的 MCP 服务器',
98
103
  importSelectAll: '全选',
99
104
  importExisting: '已存在',
@@ -119,7 +124,7 @@ export const zh = {
119
124
  marketSkills: '技能市场',
120
125
  marketMcp: 'MCP 市场',
121
126
  marketSkillsIntro: '安装技能仓库(GitHub 下载解包,实时进入扫描目录);已安装的仓库可在「技能」页管理。',
122
- marketMcpIntro: '添加 MCP 服务器行(与「MCP」页同一存储,添加后需重启生效);需要密钥的服务在安装后于列表中补填环境变量。',
127
+ marketMcpIntro: '添加 MCP 服务器行(上方可选写入当前 profile 还是全局层,添加后需重启生效);需要密钥的服务在安装后于列表中补填环境变量。',
123
128
  marketEmpty: '市场列表为空或不可用',
124
129
  marketOffline: '在线索引不可达,当前展示包内快照(可能与最新列表有差异)。',
125
130
  marketSkillCount: '{n} 个技能',
@@ -216,6 +221,11 @@ export const en = {
216
221
  scopeHint: 'Global writes DSH_HOME/cordis.patch.yml and applies to every profile on this machine; this profile writes the current profile only.',
217
222
  shadowedByGlobal: 'a global row with the same id wins — this row has no effect',
218
223
  globalLayerError: 'Failed to read the global layer (DSH_HOME/cordis.patch.yml); global servers are hidden:',
224
+ checkLabel: 'Check',
225
+ checkRunning: 'Checking…',
226
+ checkOk: 'OK',
227
+ checkFail: 'Failed',
228
+ copyToOther: 'Copy to other layer',
219
229
  serverName: 'Server name (tool name prefix)',
220
230
  transport: 'Transport',
221
231
  transportStdio: 'stdio (local command)',
@@ -232,7 +242,7 @@ export const en = {
232
242
  removeWarn: 'Removes the row from its configuration; its tools disappear after the next dsh restart.',
233
243
  emptyMcp: 'No MCP servers configured yet',
234
244
  importServers: 'Import from other agents',
235
- importIntro: 'Scans Claude Code (~/.claude.json) and Codex (~/.codex/config.toml) MCP server configs; selected entries become server rows in this profile.',
245
+ importIntro: 'Scans the MCP server configs of Claude Code (~/.claude.json), Codex (~/.codex/config.toml), Cursor (~/.cursor/mcp.json), and Gemini CLI (~/.gemini/settings.json); selected entries land in the scope picked below.',
236
246
  importEmpty: 'No importable MCP servers found',
237
247
  importSelectAll: 'Select all',
238
248
  importExisting: 'already here',
@@ -258,7 +268,7 @@ export const en = {
258
268
  marketSkills: 'Skills market',
259
269
  marketMcp: 'MCP market',
260
270
  marketSkillsIntro: 'Installs skill repositories (GitHub download, live catalog); manage installed ones on the Skills tab.',
261
- marketMcpIntro: 'Adds MCP server rows (same storage as the MCP tab; restart to apply). Servers needing keys get their environment filled in on the row after install.',
271
+ marketMcpIntro: 'Adds MCP server rows (pick the target layer this profile or global — above; restart to apply). Servers needing keys get their environment filled in on the row after install.',
262
272
  marketEmpty: 'Market list is empty or unavailable',
263
273
  marketOffline: 'Online index unreachable — showing the bundled snapshot, which may lag behind.',
264
274
  marketSkillCount: '{n} skills',
package/src/mcp.test.ts CHANGED
@@ -2,12 +2,15 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { afterAll, describe, expect, it } from 'vitest'
5
- import { listMcp, listMcpScoped, mcpScopeDir, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
5
+ import { checkMcpRow, listMcp, listMcpScoped, mcpRowToInput, mcpScopeDir, removeMcp, resolveCommandOnPath, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
6
6
 
7
7
  const root = mkdtempSync(join(tmpdir(), 'dsh-caps-mcp-'))
8
8
  const profile = join(root, 'profiles', 'web')
9
9
  const home = join(root, 'home')
10
- afterAll(() => rmSync(root, { recursive: true, force: true }))
10
+ afterAll(() => {
11
+ rmSync(root, { recursive: true, force: true })
12
+ rmSync('dsh-caps-path-bin', { recursive: true, force: true })
13
+ })
11
14
 
12
15
  const patch = () => join(profile, 'cordis.patch.yml')
13
16
 
@@ -214,3 +217,68 @@ describe('global patch layer (GitHub issue #2)', () => {
214
217
  expect(listMcp(profile).some(row => row.serverName === 'shared-tools')).toBe(false)
215
218
  })
216
219
  })
220
+
221
+ describe('mcpRowToInput', () => {
222
+ it('rebuilds a create request carrying identity fields but not the id', () => {
223
+ const rows = listMcp(profile)
224
+ const source = rows[0]
225
+ const input = mcpRowToInput(source)
226
+ expect(input.id).toBe('')
227
+ expect(input.serverName).toBe(source.serverName)
228
+ expect(input.transport).toBe(source.transport)
229
+ expect(input.command).toBe(source.command)
230
+ expect(input.args).toEqual(source.args)
231
+ expect(input.env).toEqual(source.env)
232
+ expect(validateMcpInput(input)).toBeNull()
233
+ })
234
+ })
235
+
236
+ describe('command resolution + connectivity check', () => {
237
+ it('resolves bare commands across PATH entries, honoring Windows extensions and quotes', () => {
238
+ const bin = join(root, 'bin')
239
+ mkdirSync(bin, { recursive: true })
240
+ writeFileSync(join(bin, 'tool.cmd'), '@echo off\r\n')
241
+ writeFileSync(join(bin, 'plain'), '#!/bin/sh\n')
242
+
243
+ expect(resolveCommandOnPath('tool', `"${bin}"`, 'win32')).toBe(true)
244
+ expect(resolveCommandOnPath('tool', bin, 'win32')).toBe(true)
245
+ expect(resolveCommandOnPath('tool', bin, 'linux')).toBe(false)
246
+ expect(resolveCommandOnPath('plain', bin, 'win32')).toBe(true)
247
+ expect(resolveCommandOnPath('missing-thing', bin, 'win32')).toBe(false)
248
+ expect(resolveCommandOnPath('missing-thing', `${bin};`, 'win32')).toBe(false)
249
+ expect(resolveCommandOnPath(join(bin, 'tool.cmd'), '', 'win32')).toBe(true)
250
+ expect(resolveCommandOnPath(join(bin, 'nope'), '', 'win32')).toBe(false)
251
+
252
+ // The POSIX branch splits on ':'; a Windows temp path carries a drive
253
+ // letter, so exercise it through a colon-free relative entry instead.
254
+ mkdirSync('dsh-caps-path-bin', { recursive: true })
255
+ writeFileSync(join('dsh-caps-path-bin', 'plain'), '#!/bin/sh\n')
256
+ expect(resolveCommandOnPath('plain', 'dsh-caps-path-bin:/elsewhere', 'linux')).toBe(true)
257
+ expect(resolveCommandOnPath('tool', 'dsh-caps-path-bin:/elsewhere', 'linux')).toBe(false)
258
+ })
259
+
260
+ it('checks stdio rows against PATH without spawning anything', async () => {
261
+ const base = { id: 'mcp-x', serverName: 'x', disabled: false, scope: 'profile' as const }
262
+ const missing = await checkMcpRow({ ...base, transport: 'stdio', command: 'definitely-not-a-real-cmd-xyz' }, { pathEnv: '' })
263
+ expect(missing.ok).toBe(false)
264
+ expect(missing.detail).toContain('not found on PATH')
265
+
266
+ const bin = join(root, 'bin')
267
+ const found = await checkMcpRow({ ...base, transport: 'stdio', command: 'tool' }, { pathEnv: bin, platform: 'win32' })
268
+ expect(found.ok).toBe(true)
269
+
270
+ const empty = await checkMcpRow({ ...base, transport: 'stdio' }, {})
271
+ expect(empty.ok).toBe(false)
272
+ })
273
+
274
+ it('checks http rows with a bounded GET; refusal reads as unreachable', async () => {
275
+ const base = { id: 'mcp-y', serverName: 'y', disabled: false, scope: 'profile' as const }
276
+ // Port 1 has no listener: the connect fails fast (ECONNREFUSED), which is
277
+ // exactly the signal the check exists to surface.
278
+ const refused = await checkMcpRow({ ...base, transport: 'streamable-http', url: 'http://127.0.0.1:1/mcp' }, { timeoutMs: 1500 })
279
+ expect(refused.ok).toBe(false)
280
+
281
+ const broken = await checkMcpRow({ ...base, transport: 'streamable-http' }, {})
282
+ expect(broken.ok).toBe(false)
283
+ }, 10_000)
284
+ })