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/README.md +4 -2
- package/lib/client.js +158 -32
- package/lib/client.js.map +2 -2
- package/lib/index.js +245 -38
- 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 +128 -23
- package/src/client/index.ts +12 -8
- package/src/client/locales.ts +30 -8
- package/src/index.ts +2 -1
- package/src/mcp.test.ts +135 -2
- package/src/mcp.ts +156 -18
- package/src/profile.ts +6 -2
- package/src/routes.ts +116 -26
package/package.json
CHANGED
package/src/agents.test.ts
CHANGED
|
@@ -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)
|
|
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
|
|
42
|
-
function
|
|
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
|
|
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
|
|
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 =
|
|
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
|
package/src/client/MarketTab.tsx
CHANGED
|
@@ -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
|
)}>
|
package/src/client/McpTab.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/** Settings → Plugins “MCP” tab: manage the
|
|
2
|
-
* Mutations rewrite the
|
|
3
|
-
* hands the restart to the desktop shell when one
|
|
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 }
|
|
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) =>
|
|
54
|
-
|
|
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 [
|
|
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) => {
|
|
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 (
|
|
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
|
-
|
|
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={() =>
|
|
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={
|
|
586
|
-
onClose={() =>
|
|
675
|
+
open={confirmRow !== null}
|
|
676
|
+
onClose={() => setConfirmRow(null)}
|
|
587
677
|
title={t('confirmRemove')}
|
|
588
|
-
description={
|
|
678
|
+
description={confirmRow?.serverName ?? undefined}
|
|
589
679
|
footer={
|
|
590
680
|
<>
|
|
591
|
-
<Button variant="ghost" onClick={() =>
|
|
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 && (
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
95
|
-
|
|
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
|
|