dsh-plugin-capabilities 0.3.7 → 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.7",
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)
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
  )}>
@@ -1,6 +1,7 @@
1
- /** Settings → Plugins “MCP” tab: manage the profile's mcp-client rows.
2
- * Mutations rewrite the profile patch and need a dsh restart — the banner
3
- * hands the restart to the desktop shell when one is present. */
1
+ /** Settings → Plugins “MCP” tab: manage the mcp-client rows in both patch
2
+ * layers (profile + global). Mutations rewrite the target patch and need a
3
+ * dsh restart — the banner hands the restart to the desktop shell when one
4
+ * is present. */
4
5
 
5
6
  import { useEffect, useState } from 'react'
6
7
  import type { ReactElement } from 'react'
@@ -8,11 +9,17 @@ import { Button, IconApiOutline14, IconRefreshOutline14, Modal, StateDot } from
8
9
  import { CSS } from './css.ts'
9
10
  import type { ImportedServerView, Translate } from './index.ts'
10
11
 
12
+ /** Which patch layer a row lives in. */
13
+ export type McpScope = 'global' | 'profile'
14
+
11
15
  export interface McpRow {
12
16
  id: string
13
17
  serverName: string
14
18
  transport: 'stdio' | 'streamable-http'
15
19
  disabled: boolean
20
+ scope: McpScope
21
+ /** Profile rows only: a global row with the same id composes after this one and wins. */
22
+ shadowed?: boolean
16
23
  command?: string
17
24
  args?: string[]
18
25
  env?: Record<string, string>
@@ -22,12 +29,14 @@ export interface McpRow {
22
29
  }
23
30
 
24
31
  export interface McpInjected {
25
- list(): Promise<{ servers: McpRow[] }>
32
+ list(): Promise<{ servers: McpRow[]; globalError?: string }>
26
33
  save(input: Record<string, unknown>): Promise<{ ok: boolean; id: string }>
27
- toggle(id: string, disabled: boolean): Promise<{ ok: boolean }>
28
- remove(id: string): Promise<{ ok: boolean }>
34
+ toggle(id: string, disabled: boolean, scope: McpScope): Promise<{ ok: boolean }>
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 }>
29
38
  scanImport(): Promise<{ servers: ImportedServerView[]; existing: string[] }>
30
- 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 }> }>
31
40
  restart(): Promise<void>
32
41
  desktop: boolean
33
42
  }
@@ -35,6 +44,8 @@ export interface McpInjected {
35
44
  /** Editor dialog state; null when closed. Textareas hold line-based values. */
36
45
  interface EditorState {
37
46
  id: string
47
+ /** Fixed after create: a row is edited in the layer that holds it. */
48
+ scope: McpScope
38
49
  serverName: string
39
50
  transport: 'stdio' | 'streamable-http'
40
51
  command: string
@@ -50,8 +61,13 @@ type ImportItem = { server: ImportedServerView; existing: boolean; checked: bool
50
61
 
51
62
  /** Group import candidates by source agent, known agents first. */
52
63
  export function importGroups(items: ImportItem[]): Array<{ agent: string; label: string; items: Array<{ item: ImportItem; index: number }> }> {
53
- const label = (agent: string) => agent === 'claude-code' ? 'Claude Code' : agent === 'codex' ? 'Codex' : agent
54
- 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']
55
71
  const agents = [...new Set(items.map(item => item.server.agent))]
56
72
  .sort((a, b) => {
57
73
  const rank = (agent: string) => { const at = order.indexOf(agent); return at === -1 ? order.length : at }
@@ -208,7 +224,8 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
208
224
  const { t, injected } = props
209
225
  const [servers, setServers] = useState<McpRow[] | null>(null)
210
226
  const [editor, setEditor] = useState<EditorState | null>(null)
211
- const [confirmId, setConfirmId] = useState<string | null>(null)
227
+ const [confirmRow, setConfirmRow] = useState<McpRow | null>(null)
228
+ const [globalError, setGlobalError] = useState<string | null>(null)
212
229
  const [importOpen, setImportOpen] = useState(false)
213
230
  const [importItems, setImportItems] = useState<Array<{ server: ImportedServerView; existing: boolean; checked: boolean }> | null>(null)
214
231
  const [busy, setBusy] = useState(false)
@@ -220,6 +237,9 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
220
237
  const [reload, setReload] = useState(0)
221
238
  const [pasteJson, setPasteJson] = useState('')
222
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')
223
243
 
224
244
  const openImport = async (): Promise<void> => {
225
245
  setImportOpen(true)
@@ -243,7 +263,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
243
263
  const items = importItems.filter(item => item.checked && !item.existing).map(item => ({ agent: item.server.agent, name: item.server.name }))
244
264
  setBusy(true)
245
265
  try {
246
- const body = await injected.applyImport(items)
266
+ const body = await injected.applyImport(items, importScope)
247
267
  const failed = body.results.filter(item => !item.ok)
248
268
  setOutcome(failed.length === 0
249
269
  ? { ok: true, text: t('restartNeeded') }
@@ -266,7 +286,11 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
266
286
  useEffect(() => {
267
287
  let current = true
268
288
  void injected.list().then(
269
- (body) => { if (current) setServers(body.servers) },
289
+ (body) => {
290
+ if (!current) return
291
+ setServers(body.servers)
292
+ setGlobalError(body.globalError ?? null)
293
+ },
270
294
  (error: Error) => { if (current) { setServers([]); setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }) } },
271
295
  )
272
296
  return () => { current = false }
@@ -276,7 +300,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
276
300
  setFormError(null)
277
301
  setPasteError(null)
278
302
  setPasteJson('')
279
- setEditor({ id: '', serverName: '', transport: 'stdio', command: '', args: '', env: '', url: '', headers: '' })
303
+ setEditor({ id: '', scope: 'profile', serverName: '', transport: 'stdio', command: '', args: '', env: '', url: '', headers: '' })
280
304
  }
281
305
 
282
306
  const openEdit = (row: McpRow): void => {
@@ -285,6 +309,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
285
309
  setPasteJson('')
286
310
  setEditor({
287
311
  id: row.id,
312
+ scope: row.scope,
288
313
  serverName: row.serverName,
289
314
  transport: row.transport,
290
315
  command: row.command ?? '',
@@ -336,6 +361,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
336
361
  try {
337
362
  await injected.save({
338
363
  id: editor.id,
364
+ scope: editor.scope,
339
365
  serverName: editor.serverName.trim(),
340
366
  transport: editor.transport,
341
367
  ...(editor.transport === 'stdio'
@@ -362,7 +388,7 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
362
388
  const doToggle = async (row: McpRow): Promise<void> => {
363
389
  setBusy(true)
364
390
  try {
365
- await injected.toggle(row.id, !row.disabled)
391
+ await injected.toggle(row.id, !row.disabled, row.scope)
366
392
  setOutcome({ ok: true, text: t('restartNeeded') })
367
393
  reloadList(true)
368
394
  } catch (error) {
@@ -373,17 +399,44 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
373
399
  }
374
400
 
375
401
  const doRemove = async (): Promise<void> => {
376
- if (confirmId === null) return
402
+ if (confirmRow === null) return
403
+ setBusy(true)
404
+ try {
405
+ await injected.remove(confirmRow.id, confirmRow.scope)
406
+ setOutcome({ ok: true, text: t('restartNeeded') })
407
+ reloadList(true)
408
+ } catch (error) {
409
+ setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
410
+ } finally {
411
+ setBusy(false)
412
+ setConfirmRow(null)
413
+ }
414
+ }
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> => {
377
430
  setBusy(true)
378
431
  try {
379
- await injected.remove(confirmId)
432
+ const toScope: McpScope = row.scope === 'global' ? 'profile' : 'global'
433
+ await injected.copy(row.id, row.scope, toScope)
380
434
  setOutcome({ ok: true, text: t('restartNeeded') })
381
435
  reloadList(true)
382
436
  } catch (error) {
383
437
  setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
384
438
  } finally {
385
439
  setBusy(false)
386
- setConfirmId(null)
387
440
  }
388
441
  }
389
442
 
@@ -442,6 +495,15 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
442
495
  <div className="dpc-bannerBody"><span>{outcome.text}</span></div>
443
496
  </div>
444
497
  )}
498
+ {globalError !== null && (
499
+ <div className="dpc-banner" data-kind="error" role="alert">
500
+ <StateDot state="error" size={10} />
501
+ <div className="dpc-bannerBody">
502
+ <span>{t('globalLayerError')}</span>
503
+ <span className="dpc-bannerHint">{globalError}</span>
504
+ </div>
505
+ </div>
506
+ )}
445
507
  {(pending || restarting) && restartBanner}
446
508
 
447
509
  <div className="dpc-listHead">
@@ -458,20 +520,35 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
458
520
  {servers !== null && servers.length > 0 && (
459
521
  <ul className="dpc-cards">
460
522
  {servers.map((row) => (
461
- <li className="dpc-card" key={row.id}>
523
+ <li className="dpc-card" key={`${row.scope}/${row.id}`}>
462
524
  <div className="dpc-cardTop">
463
525
  <strong className="dpc-cardTitle" title={row.id}>{row.serverName}</strong>
526
+ <span className="dpc-tag" data-kind={row.scope === 'global' ? 'source' : undefined}>{row.scope === 'global' ? t('scopeGlobal') : t('scopeProfile')}</span>
464
527
  <span className="dpc-tag">{row.transport}</span>
465
528
  <span className="dpc-tag" data-kind={row.disabled ? 'off' : undefined}>{row.disabled ? t('disabled') : t('enabled')}</span>
466
529
  </div>
467
530
  <p className="dpc-cardDesc">
468
531
  {row.transport === 'stdio' ? `${row.command ?? ''} ${(row.args ?? []).join(' ')}` : row.url ?? ''}
469
532
  </p>
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
+ })()}
470
545
  <div className="dpc-cardRow">
471
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>
472
549
  <Button variant="ghost" size="sm" disabled={busy} onClick={() => void doToggle(row)}>{t('toggle')}</Button>
473
550
  <Button variant="ghost" size="sm" disabled={busy} onClick={() => openEdit(row)}>{t('edit')}</Button>
474
- <Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmId(row.id)}>{t('delete')}</Button>
551
+ <Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmRow(row)}>{t('delete')}</Button>
475
552
  </div>
476
553
  </li>
477
554
  ))}
@@ -487,6 +564,19 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
487
564
  >
488
565
  {editor !== null && (
489
566
  <div className="dpc-form">
567
+ <label className="dpc-label">
568
+ <span>{t('scopeLabel')}</span>
569
+ <select
570
+ className="dpc-select"
571
+ value={editor.scope}
572
+ disabled={editor.id !== ''}
573
+ onChange={(event) => setEditor({ ...editor, scope: event.target.value as McpScope })}
574
+ >
575
+ <option value="profile">{t('scopeProfile')}</option>
576
+ <option value="global">{t('scopeGlobal')}</option>
577
+ </select>
578
+ <span className="dpc-formatHint">{t('scopeHint')}</span>
579
+ </label>
490
580
  <label className="dpc-label">
491
581
  <span>{t('serverName')}</span>
492
582
  <input
@@ -582,13 +672,13 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
582
672
  </Modal>
583
673
 
584
674
  <Modal
585
- open={confirmId !== null}
586
- onClose={() => setConfirmId(null)}
675
+ open={confirmRow !== null}
676
+ onClose={() => setConfirmRow(null)}
587
677
  title={t('confirmRemove')}
588
- description={confirmId ?? undefined}
678
+ description={confirmRow?.serverName ?? undefined}
589
679
  footer={
590
680
  <>
591
- <Button variant="ghost" onClick={() => setConfirmId(null)}>{t('cancel')}</Button>
681
+ <Button variant="ghost" onClick={() => setConfirmRow(null)}>{t('cancel')}</Button>
592
682
  <Button variant="primary" disabled={busy} onClick={() => void doRemove()}>{t('delete')}</Button>
593
683
  </>
594
684
  }
@@ -618,6 +708,21 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
618
708
  >
619
709
  <div className="dpc-form">
620
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>
621
726
  {importItems === null && <p className="dpc-empty">{t('loading')}</p>}
622
727
  {importItems !== null && importItems.length === 0 && <p className="dpc-empty">{t('importEmpty')}</p>}
623
728
  {importItems !== null && importItems.length > 0 && (
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { createElement as h } from 'react'
6
6
  import { CapabilitiesSection } from './CapabilitiesSection.tsx'
7
- import type { McpInjected, McpRow } from './McpTab.tsx'
7
+ import type { McpInjected, McpRow, McpScope } from './McpTab.tsx'
8
8
  import type { MarketInjected, RootRowView } from './MarketTab.tsx'
9
9
  import type { SkillsInjected, SkillRowView } from './SkillsTab.tsx'
10
10
  import { zh, en } from './locales.ts'
@@ -89,13 +89,17 @@ export function apply(ctx: CapabilitiesClientContext): void {
89
89
  }
90
90
 
91
91
  const mcpInjected: McpInjected = {
92
- list: () => fetchJson<{ servers: McpRow[] }>('/dsh-plugin-capabilities/mcp'),
92
+ list: () => fetchJson<{ servers: McpRow[]; globalError?: string }>('/dsh-plugin-capabilities/mcp'),
93
93
  save: (input: unknown) => post('/dsh-plugin-capabilities/mcp/save', input) as Promise<{ ok: boolean; id: string }>,
94
- toggle: (id: string, disabled: boolean) => post('/dsh-plugin-capabilities/mcp/toggle', { id, disabled }) as Promise<{ ok: boolean }>,
95
- remove: (id: string) => post('/dsh-plugin-capabilities/mcp/remove', { id }) as Promise<{ ok: boolean }>,
94
+ toggle: (id: string, disabled: boolean, scope: McpScope) =>
95
+ post('/dsh-plugin-capabilities/mcp/toggle', { id, disabled, scope }) as Promise<{ ok: boolean }>,
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 }>,
96
100
  scanImport: () => fetchJson<{ servers: ImportedServerView[]; existing: string[] }>('/dsh-plugin-capabilities/import/scan'),
97
- applyImport: (items: Array<{ agent: string; name: string }>) =>
98
- 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 }> }>,
99
103
  restart: async (): Promise<void> => {
100
104
  if (window.dshDesktop !== undefined) {
101
105
  window.dshDesktop.restartSidecar?.()
@@ -112,8 +116,8 @@ export function apply(ctx: CapabilitiesClientContext): void {
112
116
  mcpIndex: () => fetchJson<{ source: 'remote' | 'bundled'; servers: Array<MarketServerView & { installed: boolean }> }>('/dsh-plugin-capabilities/market/mcp'),
113
117
  installSkillRepo: (url: string) =>
114
118
  post('/dsh-plugin-capabilities/market/skills/install', { url }) as Promise<{ ok: boolean; root: RootRowView }>,
115
- installMcp: (id: string) =>
116
- 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 }>,
117
121
  removeRoot: skillsInjected.removeRoot,
118
122
  }
119
123