dsh-plugin-capabilities 0.1.5 → 0.2.0

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.
Files changed (51) hide show
  1. package/README.md +16 -2
  2. package/lib/client.js +936 -220
  3. package/lib/client.js.map +4 -4
  4. package/lib/index.js +813 -38
  5. package/lib/index.js.map +4 -4
  6. package/market/mcp.json +164 -0
  7. package/market/skills.json +36 -0
  8. package/package.json +3 -1
  9. package/skills/find-skills/LICENSE +21 -0
  10. package/skills/find-skills/SKILL.md +141 -0
  11. package/skills/skill-creator/LICENSE.txt +202 -0
  12. package/skills/skill-creator/SKILL.md +485 -0
  13. package/skills/skill-creator/agents/analyzer.md +274 -0
  14. package/skills/skill-creator/agents/comparator.md +202 -0
  15. package/skills/skill-creator/agents/grader.md +223 -0
  16. package/skills/skill-creator/assets/eval_review.html +146 -0
  17. package/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  18. package/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  19. package/skills/skill-creator/references/schemas.md +430 -0
  20. package/skills/skill-creator/scripts/__init__.py +0 -0
  21. package/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  22. package/skills/skill-creator/scripts/generate_report.py +326 -0
  23. package/skills/skill-creator/scripts/improve_description.py +247 -0
  24. package/skills/skill-creator/scripts/package_skill.py +136 -0
  25. package/skills/skill-creator/scripts/quick_validate.py +103 -0
  26. package/skills/skill-creator/scripts/run_eval.py +310 -0
  27. package/skills/skill-creator/scripts/run_loop.py +328 -0
  28. package/skills/skill-creator/scripts/utils.py +47 -0
  29. package/src/client/CapabilitiesSection.tsx +11 -7
  30. package/src/client/MarketTab.tsx +263 -0
  31. package/src/client/McpTab.tsx +198 -0
  32. package/src/client/SkillsTab.tsx +261 -26
  33. package/src/client/css.ts +42 -0
  34. package/src/client/index.ts +58 -5
  35. package/src/client/locales.ts +112 -2
  36. package/src/index.ts +76 -20
  37. package/src/market.test.ts +57 -0
  38. package/src/market.ts +166 -0
  39. package/src/opener.ts +26 -0
  40. package/src/packaged-skills.test.ts +40 -0
  41. package/src/repos.test.ts +213 -0
  42. package/src/repos.ts +207 -0
  43. package/src/routes.ts +336 -6
  44. package/src/skills.test.ts +45 -1
  45. package/src/skills.ts +22 -1
  46. package/src/smoke.test.ts +61 -2
  47. package/src/state.test.ts +64 -0
  48. package/src/state.ts +109 -0
  49. package/src/tar.test.ts +100 -0
  50. package/src/tar.ts +154 -0
  51. package/src/types.ts +11 -1
@@ -0,0 +1,263 @@
1
+ /** Settings “市场” tab: browse and install curated skill repositories and
2
+ * MCP servers. Two segments (skills / MCP) with independently fetched
3
+ * indexes; installs reuse the repositories and MCP-row plumbing, so a
4
+ * skills install lands live in the catalog and an MCP install raises the
5
+ * same pending-restart notice as the MCP tab. */
6
+
7
+ import { useCallback, useEffect, useState } from 'react'
8
+ import type { ReactElement } from 'react'
9
+ import { Button, IconRefreshOutline14, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
10
+ import { CSS } from './css.ts'
11
+ import type { MarketRepoView, MarketServerView, Translate } from './index.ts'
12
+ import type { McpInjected } from './McpTab.tsx'
13
+
14
+ /** One registered skill repository as the roots route reports it. */
15
+ export interface RootRowView {
16
+ id: string
17
+ kind: 'local' | 'git'
18
+ label: string
19
+ url?: string
20
+ ref?: string
21
+ path?: string
22
+ roots: string[]
23
+ materialDir?: string
24
+ addedAt: number
25
+ /** False once one of its scan roots vanished from disk. */
26
+ live: boolean
27
+ }
28
+
29
+ export interface MarketInjected {
30
+ skillsIndex(): Promise<{ source: 'remote' | 'bundled'; repos: Array<MarketRepoView & { installedId: string | null }> }>
31
+ mcpIndex(): Promise<{ source: 'remote' | 'bundled'; servers: Array<MarketServerView & { installed: boolean }> }>
32
+ installSkillRepo(url: string): Promise<{ ok: boolean; root: RootRowView }>
33
+ installMcp(id: string): Promise<{ ok: boolean; id: string }>
34
+ removeRoot(id: string): Promise<{ ok: boolean }>
35
+ }
36
+
37
+ /** UI language hint for the index's zh display fields. */
38
+ const zhUi = (): boolean => navigator.language.toLowerCase().startsWith('zh')
39
+
40
+ type Segment = 'skills' | 'mcp'
41
+
42
+ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: McpInjected }): ReactElement {
43
+ const { t, market, mcp } = props
44
+ const [segment, setSegment] = useState<Segment>('skills')
45
+ const [repos, setRepos] = useState<Array<MarketRepoView & { installedId: string | null }> | null>(null)
46
+ const [servers, setServers] = useState<Array<MarketServerView & { installed: boolean }> | null>(null)
47
+ const [source, setSource] = useState<'remote' | 'bundled'>('remote')
48
+ const [busyId, setBusyId] = useState<string | null>(null)
49
+ 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 [reload, setReload] = useState(0)
52
+
53
+ const refreshSkills = useCallback((): void => {
54
+ setRepos(null)
55
+ void market.skillsIndex().then(
56
+ (body) => { setRepos(body.repos); setSource(body.source) },
57
+ (error: Error) => { setRepos([]); setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }) },
58
+ )
59
+ }, [market, t])
60
+
61
+ const refreshMcp = useCallback((): void => {
62
+ setServers(null)
63
+ void market.mcpIndex().then(
64
+ (body) => { setServers(body.servers); setSource(body.source) },
65
+ (error: Error) => { setServers([]); setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }) },
66
+ )
67
+ }, [market, t])
68
+
69
+ useEffect(() => {
70
+ // Refetch the visible segment when it changes or the reload ticks; the
71
+ // hidden segment keeps its last state as a cache.
72
+ if (segment === 'skills') refreshSkills()
73
+ else refreshMcp()
74
+ }, [segment, reload, refreshSkills, refreshMcp])
75
+
76
+ const doInstallRepo = async (repo: MarketRepoView): Promise<void> => {
77
+ setBusyId(repo.id)
78
+ setOutcome(null)
79
+ try {
80
+ await market.installSkillRepo(repo.url)
81
+ setOutcome({ ok: true, text: t('marketRepoInstalled') })
82
+ refreshSkills()
83
+ } catch (error) {
84
+ setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
85
+ refreshSkills()
86
+ } finally {
87
+ setBusyId(null)
88
+ }
89
+ }
90
+
91
+ const doInstallMcp = async (server: MarketServerView): Promise<void> => {
92
+ setBusyId(server.id)
93
+ setOutcome(null)
94
+ try {
95
+ await market.installMcp(server.id)
96
+ setOutcome({ ok: true, text: t('restartNeeded') })
97
+ refreshMcp()
98
+ } catch (error) {
99
+ setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
100
+ refreshMcp()
101
+ } finally {
102
+ setBusyId(null)
103
+ }
104
+ }
105
+
106
+ const doUninstall = async (): Promise<void> => {
107
+ if (confirmUninstall === null) return
108
+ const target = confirmUninstall
109
+ setBusyId(target.id)
110
+ try {
111
+ if (target.kind === 'root') {
112
+ await market.removeRoot(target.id)
113
+ setOutcome({ ok: true, text: t('rootRemoved') })
114
+ refreshSkills()
115
+ } else {
116
+ await mcp.remove(target.id)
117
+ setOutcome({ ok: true, text: t('restartNeeded') })
118
+ refreshMcp()
119
+ }
120
+ } catch (error) {
121
+ setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
122
+ } finally {
123
+ setBusyId(null)
124
+ setConfirmUninstall(null)
125
+ }
126
+ }
127
+
128
+ const pick = <T,>(base: T, zh: T | undefined): T => (zhUi() && zh !== undefined ? zh : base)
129
+ const title = (entry: { name: string; nameZh?: string }): string => pick(entry.name, entry.nameZh)
130
+ const desc = (entry: { description: string; descriptionZh?: string }): string => pick(entry.description, entry.descriptionZh)
131
+
132
+ return (
133
+ <div className="dpc-section">
134
+ <style>{CSS}</style>
135
+
136
+ <div className="dpc-head">
137
+ <h3>{t('marketTitle')}</h3>
138
+ <span className="dpc-spacer" />
139
+ <button type="button" className="dpc-refresh" aria-label={t('refresh')} title={t('refresh')} onClick={() => setReload((value) => value + 1)}>
140
+ <IconRefreshOutline14 size={14} aria-hidden="true" />
141
+ </button>
142
+ </div>
143
+ <p className="dpc-intro">{t('marketIntro')}</p>
144
+
145
+ <div className="dpc-segments" role="tablist" aria-label={t('marketTitle')}>
146
+ <button type="button" role="tab" aria-selected={segment === 'skills'} className="dpc-segment" data-active={segment === 'skills' ? 'true' : undefined} onClick={() => setSegment('skills')}>
147
+ {t('marketSkills')}
148
+ </button>
149
+ <button type="button" role="tab" aria-selected={segment === 'mcp'} className="dpc-segment" data-active={segment === 'mcp' ? 'true' : undefined} onClick={() => setSegment('mcp')}>
150
+ {t('marketMcp')}
151
+ </button>
152
+ </div>
153
+
154
+ {outcome !== null && (
155
+ <div className="dpc-banner" data-kind={outcome.ok ? 'ok' : 'error'} role="status">
156
+ <StateDot state={outcome.ok ? 'done' : 'error'} size={10} />
157
+ <div className="dpc-bannerBody"><span>{outcome.text}</span></div>
158
+ </div>
159
+ )}
160
+ {source === 'bundled' && (
161
+ <div className="dpc-banner" data-kind="info" role="status">
162
+ <StateDot state="ongoing" size={10} />
163
+ <div className="dpc-bannerBody"><span>{t('marketOffline')}</span></div>
164
+ </div>
165
+ )}
166
+
167
+ {segment === 'skills' && (
168
+ <>
169
+ <p className="dpc-intro">{t('marketSkillsIntro')}</p>
170
+ {repos === null && <p className="dpc-empty">{t('loading')}</p>}
171
+ {repos !== null && repos.length === 0 && <p className="dpc-empty">{t('marketEmpty')}</p>}
172
+ {repos !== null && repos.length > 0 && (
173
+ <ul className="dpc-cards">
174
+ {repos.map((repo) => (
175
+ <li className="dpc-card" key={repo.id}>
176
+ <div className="dpc-cardTop">
177
+ <strong className="dpc-cardTitle" title={repo.url}>{title(repo)}</strong>
178
+ {repo.skillCount !== undefined && <span className="dpc-tag">{t('marketSkillCount').replace('{n}', String(repo.skillCount))}</span>}
179
+ {repo.installedId !== null && <span className="dpc-tag" data-kind="source">{t('marketInstalled')}</span>}
180
+ </div>
181
+ <p className="dpc-cardDesc">{desc(repo)}</p>
182
+ <div className="dpc-cardRow">
183
+ <a className="dpc-link" href={repo.homepage ?? repo.url} target="_blank" rel="noreferrer">{t('marketHome')}</a>
184
+ <span className="dpc-spacer" />
185
+ {repo.installedId !== null ? (
186
+ <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={() => setConfirmUninstall({ kind: 'root', id: repo.installedId as string, name: title(repo) })}>
187
+ {t('marketUninstall')}
188
+ </Button>
189
+ ) : (
190
+ <Button variant="primary" size="sm" disabled={busyId !== null} onClick={() => void doInstallRepo(repo)}>
191
+ {busyId === repo.id ? t('marketInstalling') : t('marketInstall')}
192
+ </Button>
193
+ )}
194
+ </div>
195
+ </li>
196
+ ))}
197
+ </ul>
198
+ )}
199
+ </>
200
+ )}
201
+
202
+ {segment === 'mcp' && (
203
+ <>
204
+ <p className="dpc-intro">{t('marketMcpIntro')}</p>
205
+ {servers === null && <p className="dpc-empty">{t('loading')}</p>}
206
+ {servers !== null && servers.length === 0 && <p className="dpc-empty">{t('marketEmpty')}</p>}
207
+ {servers !== null && servers.length > 0 && (
208
+ <ul className="dpc-cards">
209
+ {servers.map((server) => (
210
+ <li className="dpc-card" key={server.id}>
211
+ <div className="dpc-cardTop">
212
+ <strong className="dpc-cardTitle" title={server.id}>{title(server)}</strong>
213
+ <span className="dpc-tag">{server.transport}</span>
214
+ {server.runtime !== undefined && <span className="dpc-tag">{server.runtime}</span>}
215
+ {server.installed && <span className="dpc-tag" data-kind="source">{t('marketInstalled')}</span>}
216
+ </div>
217
+ <p className="dpc-cardDesc">{desc(server)}</p>
218
+ {server.envKeys !== undefined && server.envKeys.length > 0 && (
219
+ <p className="dpc-envHint">{t('marketNeedsEnv')}: {server.envKeys.join(', ')}</p>
220
+ )}
221
+ <div className="dpc-cardRow">
222
+ <a className="dpc-link" href={server.homepage} target="_blank" rel="noreferrer">{t('marketHome')}</a>
223
+ <span className="dpc-spacer" />
224
+ {server.installed ? (
225
+ <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={() => void mcp.list().then(
226
+ (body) => {
227
+ const row = body.servers.find(item => item.serverName === server.id)
228
+ if (row !== undefined) setConfirmUninstall({ kind: 'mcp', id: row.id, name: server.id })
229
+ },
230
+ (error: Error) => setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }),
231
+ )}>
232
+ {t('marketUninstall')}
233
+ </Button>
234
+ ) : (
235
+ <Button variant="primary" size="sm" disabled={busyId !== null} onClick={() => void doInstallMcp(server)}>
236
+ {busyId === server.id ? t('marketInstalling') : t('marketAdd')}
237
+ </Button>
238
+ )}
239
+ </div>
240
+ </li>
241
+ ))}
242
+ </ul>
243
+ )}
244
+ </>
245
+ )}
246
+
247
+ <Modal
248
+ open={confirmUninstall !== null}
249
+ onClose={() => setConfirmUninstall(null)}
250
+ title={t('marketUninstallConfirm')}
251
+ description={confirmUninstall?.name ?? undefined}
252
+ footer={
253
+ <>
254
+ <Button variant="ghost" onClick={() => setConfirmUninstall(null)}>{t('cancel')}</Button>
255
+ <Button variant="primary" disabled={busyId !== null} onClick={() => void doUninstall()}>{t('marketUninstall')}</Button>
256
+ </>
257
+ }
258
+ >
259
+ <p>{confirmUninstall?.kind === 'root' ? t('marketUninstallRootWarn') : t('removeWarn')}</p>
260
+ </Modal>
261
+ </div>
262
+ )
263
+ }
@@ -82,6 +82,128 @@ function mapToPairs(map: Record<string, string> | undefined, separator: string):
82
82
  return Object.entries(map).map(([key, value]) => `${key}${separator}${value.includes('\n') ? JSON.stringify(value) : value}`).join('\n')
83
83
  }
84
84
 
85
+ /** YAML double-quoted scalar (JSON syntax is valid YAML); plain for simple ids. */
86
+ function yamlScalar(value: string): string {
87
+ return /^[A-Za-z0-9_./@-]+$/.test(value) ? value : JSON.stringify(value)
88
+ }
89
+
90
+ /** Shape the profile patch row exactly as the host would persist it (preview). */
91
+ export function mcpRowYaml(input: { serverName: string; transport: 'stdio' | 'streamable-http'; command?: string; args?: string[]; env?: Record<string, string>; url?: string; headers?: Record<string, string> }): string {
92
+ const name = input.serverName.trim() === '' ? 'server-name' : input.serverName.trim()
93
+ const lines = [
94
+ '- insert:',
95
+ ` - id: mcp-${name}`,
96
+ " name: '@deepseek-ai/dsh-mcp-client'",
97
+ ' config:',
98
+ ` serverName: ${yamlScalar(name)}`,
99
+ ` transport: ${input.transport}`,
100
+ ]
101
+ if (input.transport === 'stdio') {
102
+ lines.push(` command: ${yamlScalar(input.command ?? '')}`)
103
+ const args = input.args ?? []
104
+ if (args.length > 0) {
105
+ lines.push(' args:')
106
+ for (const arg of args) lines.push(` - ${yamlScalar(arg)}`)
107
+ }
108
+ const env = input.env ?? {}
109
+ if (Object.keys(env).length > 0) {
110
+ lines.push(' env:')
111
+ for (const [key, value] of Object.entries(env)) lines.push(` ${yamlScalar(key)}: ${yamlScalar(value)}`)
112
+ }
113
+ } else {
114
+ lines.push(` url: ${yamlScalar(input.url ?? '')}`)
115
+ const headers = input.headers ?? {}
116
+ if (Object.keys(headers).length > 0) {
117
+ lines.push(' headers:')
118
+ for (const [key, value] of Object.entries(headers)) lines.push(` ${yamlScalar(key)}: ${yamlScalar(value)}`)
119
+ }
120
+ }
121
+ return lines.join('\n')
122
+ }
123
+
124
+ /** The equivalent mcpServers JSON a foreign config or docs page would show. */
125
+ export function mcpJsonExample(transport: 'stdio' | 'streamable-http'): string {
126
+ const entry = transport === 'stdio'
127
+ ? {
128
+ command: 'npx',
129
+ args: ['-y', '@example/mcp-server'],
130
+ env: { API_KEY: 'value' },
131
+ }
132
+ : {
133
+ type: 'http',
134
+ url: 'https://example.com/mcp',
135
+ headers: { Authorization: 'Bearer <token>' },
136
+ }
137
+ return JSON.stringify({ mcpServers: { 'server-name': entry } }, null, 2)
138
+ }
139
+
140
+ /** Fields parsed out of a pasted MCP JSON config (any common shape). */
141
+ export interface ParsedMcpJson {
142
+ serverName?: string
143
+ transport: 'stdio' | 'streamable-http'
144
+ command?: string
145
+ args?: string[]
146
+ env?: Record<string, string>
147
+ url?: string
148
+ headers?: Record<string, string>
149
+ }
150
+
151
+ /** Parse one MCP server from pasted JSON: a bare entry, a dsh row, or a
152
+ * `{"mcpServers": {…}}` wrapper (first entry wins). Returns the reason on bad input. */
153
+ export function parseMcpJson(text: string): ParsedMcpJson | { error: string } {
154
+ let parsed: unknown
155
+ try {
156
+ parsed = JSON.parse(text)
157
+ } catch {
158
+ return { error: 'not valid JSON' }
159
+ }
160
+ if (typeof parsed !== 'object' || parsed === null) return { error: 'expected a JSON object' }
161
+ let record = parsed as Record<string, unknown>
162
+ let nameFromWrapper: string | undefined
163
+ const wrapped = record.mcpServers ?? record.mcp_servers ?? record.servers
164
+ if (typeof wrapped === 'object' && wrapped !== null && !Array.isArray(wrapped)) {
165
+ const first = Object.entries(wrapped as Record<string, unknown>)[0]
166
+ if (first === undefined) return { error: 'mcpServers object is empty' }
167
+ nameFromWrapper = first[0]
168
+ if (typeof first[1] !== 'object' || first[1] === null) return { error: 'server entry is not an object' }
169
+ record = first[1] as Record<string, unknown>
170
+ }
171
+ const stringMap = (value: unknown): Record<string, string> | undefined => {
172
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
173
+ const out: Record<string, string> = {}
174
+ for (const [key, entry] of Object.entries(value)) {
175
+ if (typeof entry === 'string') out[key] = entry
176
+ }
177
+ return Object.keys(out).length > 0 ? out : undefined
178
+ }
179
+ const args = Array.isArray(record.args) && record.args.every(entry => typeof entry === 'string')
180
+ ? record.args as string[]
181
+ : undefined
182
+ const command = typeof record.command === 'string' ? record.command : undefined
183
+ const url = typeof record.url === 'string' ? record.url : undefined
184
+ const declared = typeof record.type === 'string' ? record.type : typeof record.transport === 'string' ? record.transport : undefined
185
+ const httpDeclared = declared === 'http' || declared === 'streamable-http' || declared === 'sse'
186
+ const transport: 'stdio' | 'streamable-http' = command !== undefined && !httpDeclared
187
+ ? 'stdio'
188
+ : url !== undefined ? 'streamable-http' : httpDeclared ? 'streamable-http' : 'stdio'
189
+ if (transport === 'stdio' && command === undefined) return { error: 'stdio config needs a "command" field' }
190
+ if (transport === 'streamable-http' && url === undefined) return { error: 'http config needs a "url" field' }
191
+ const serverName = nameFromWrapper
192
+ ?? (typeof record.serverName === 'string' ? record.serverName : undefined)
193
+ ?? (typeof record.name === 'string' && record.name !== '@deepseek-ai/dsh-mcp-client' ? record.name : undefined)
194
+ const env = stringMap(record.env)
195
+ const headers = stringMap(record.headers)
196
+ return {
197
+ ...(serverName !== undefined ? { serverName } : {}),
198
+ transport,
199
+ ...(command !== undefined ? { command } : {}),
200
+ ...(args !== undefined ? { args } : {}),
201
+ ...(env !== undefined ? { env } : {}),
202
+ ...(url !== undefined ? { url } : {}),
203
+ ...(headers !== undefined ? { headers } : {}),
204
+ }
205
+ }
206
+
85
207
  export function McpTab(props: { t: Translate; injected: McpInjected }): ReactElement {
86
208
  const { t, injected } = props
87
209
  const [servers, setServers] = useState<McpRow[] | null>(null)
@@ -96,6 +218,8 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
96
218
  const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
97
219
  const [formError, setFormError] = useState<string | null>(null)
98
220
  const [reload, setReload] = useState(0)
221
+ const [pasteJson, setPasteJson] = useState('')
222
+ const [pasteError, setPasteError] = useState<string | null>(null)
99
223
 
100
224
  const openImport = async (): Promise<void> => {
101
225
  setImportOpen(true)
@@ -150,11 +274,15 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
150
274
 
151
275
  const openCreate = (): void => {
152
276
  setFormError(null)
277
+ setPasteError(null)
278
+ setPasteJson('')
153
279
  setEditor({ id: '', serverName: '', transport: 'stdio', command: '', args: '', env: '', url: '', headers: '' })
154
280
  }
155
281
 
156
282
  const openEdit = (row: McpRow): void => {
157
283
  setFormError(null)
284
+ setPasteError(null)
285
+ setPasteJson('')
158
286
  setEditor({
159
287
  id: row.id,
160
288
  serverName: row.serverName,
@@ -167,6 +295,40 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
167
295
  })
168
296
  }
169
297
 
298
+ /** Fill the form from pasted JSON (mcpServers wrapper, bare entry, dsh row). */
299
+ const doPasteFill = (): void => {
300
+ if (editor === null || pasteJson.trim() === '') return
301
+ const parsed = parseMcpJson(pasteJson)
302
+ if ('error' in parsed) {
303
+ setPasteError(parsed.error)
304
+ return
305
+ }
306
+ // Existing rows keep their identity (serverName + transport); a pasted
307
+ // config of the other transport cannot apply to them.
308
+ const lockIdentity = editor.id !== ''
309
+ if (lockIdentity && parsed.transport !== editor.transport) {
310
+ setPasteError(t('pasteTransportMismatch'))
311
+ return
312
+ }
313
+ setPasteError(null)
314
+ setFormError(null)
315
+ setEditor({
316
+ ...editor,
317
+ ...(parsed.serverName !== undefined && !lockIdentity ? { serverName: parsed.serverName } : {}),
318
+ ...(!lockIdentity ? { transport: parsed.transport } : {}),
319
+ ...(parsed.transport === 'stdio'
320
+ ? {
321
+ command: parsed.command ?? editor.command,
322
+ args: (parsed.args ?? []).join('\n'),
323
+ env: mapToPairs(parsed.env, '='),
324
+ }
325
+ : {
326
+ url: parsed.url ?? editor.url,
327
+ headers: mapToPairs(parsed.headers, ':'),
328
+ }),
329
+ })
330
+ }
331
+
170
332
  const doSave = async (): Promise<void> => {
171
333
  if (editor === null) return
172
334
  setBusy(true)
@@ -373,6 +535,42 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
373
535
  </label>
374
536
  </>
375
537
  )}
538
+ <details className="dpc-format">
539
+ <summary>{t('formatTitle')}</summary>
540
+ <div className="dpc-form">
541
+ <label className="dpc-label">
542
+ <span>{t('formatPaste')}</span>
543
+ <textarea
544
+ className="dpc-textarea"
545
+ data-short="true"
546
+ placeholder={'{\n "mcpServers": { "name": { "command": "npx", "args": ["…"] } } }\n'}
547
+ value={pasteJson}
548
+ onChange={(event) => setPasteJson(event.target.value)}
549
+ />
550
+ </label>
551
+ {pasteError !== null && <p className="dpc-formError">{pasteError}</p>}
552
+ <div className="dpc-cardRow">
553
+ <Button variant="outline" size="sm" disabled={pasteJson.trim() === ''} onClick={doPasteFill}>{t('formatFill')}</Button>
554
+ </div>
555
+ <p className="dpc-formatHint">{t('formatYamlHint')}</p>
556
+ <pre className="dpc-code">{mcpRowYaml({
557
+ serverName: editor.serverName,
558
+ transport: editor.transport,
559
+ ...(editor.transport === 'stdio'
560
+ ? {
561
+ command: editor.command.trim(),
562
+ args: editor.args.split(/\r?\n/).map(line => line.trim()).filter(line => line !== ''),
563
+ env: parsePairs(editor.env, '='),
564
+ }
565
+ : {
566
+ url: editor.url.trim(),
567
+ headers: parsePairs(editor.headers, ':'),
568
+ }),
569
+ })}</pre>
570
+ <p className="dpc-formatHint">{t('formatJsonHint')}</p>
571
+ <pre className="dpc-code">{mcpJsonExample(editor.transport)}</pre>
572
+ </div>
573
+ </details>
376
574
  {formError !== null && <p className="dpc-formError">{formError}</p>}
377
575
  <div className="dpc-cardRow">
378
576
  <span className="dpc-spacer" />