dsh-plugin-capabilities 0.3.8 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/lib/client.js +96 -13
- package/lib/client.js.map +2 -2
- package/lib/index.js +175 -8
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
- package/src/agents.test.ts +47 -1
- package/src/agents.ts +59 -8
- package/src/client/MarketTab.tsx +23 -7
- package/src/client/McpTab.tsx +69 -4
- package/src/client/index.ts +7 -4
- package/src/client/locales.ts +14 -4
- package/src/mcp.test.ts +70 -2
- package/src/mcp.ts +78 -0
- package/src/routes.ts +81 -6
package/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
|
@@ -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 }
|
|
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) =>
|
|
63
|
-
|
|
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 && (
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
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 (
|
|
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(() =>
|
|
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
|
+
})
|