dsh-plugin-capabilities 0.2.1 → 0.3.1

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.
@@ -48,6 +48,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
48
48
  const [busyId, setBusyId] = useState<string | null>(null)
49
49
  const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
50
50
  const [confirmUninstall, setConfirmUninstall] = useState<{ kind: 'root' | 'mcp'; id: string; name: string } | null>(null)
51
+ const [detail, setDetail] = useState<{ kind: Segment; id: string } | null>(null)
51
52
  const [reload, setReload] = useState(0)
52
53
 
53
54
  const refreshSkills = useCallback((): void => {
@@ -128,6 +129,15 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
128
129
  const pick = <T,>(base: T, zh: T | undefined): T => (zhUi() && zh !== undefined ? zh : base)
129
130
  const title = (entry: { name: string; nameZh?: string }): string => pick(entry.name, entry.nameZh)
130
131
  const desc = (entry: { description: string; descriptionZh?: string }): string => pick(entry.description, entry.descriptionZh)
132
+ const longText = (entry: { description: string; descriptionZh?: string; detail?: string; detailZh?: string }): string =>
133
+ entry.detail !== undefined ? pick(entry.detail, entry.detailZh) : desc(entry)
134
+
135
+ const cardStop = (event: { stopPropagation(): void }): void => { event.stopPropagation() }
136
+
137
+ // The card list refreshes on install/uninstall, so the detail modal resolves
138
+ // its entry by id each render and follows the fresh installed state.
139
+ const detailRepo = detail !== null && detail.kind === 'skills' && repos !== null ? repos.find(repo => repo.id === detail.id) ?? null : null
140
+ const detailServer = detail !== null && detail.kind === 'mcp' && servers !== null ? servers.find(server => server.id === detail.id) ?? null : null
131
141
 
132
142
  return (
133
143
  <div className="dpc-section">
@@ -172,7 +182,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
172
182
  {repos !== null && repos.length > 0 && (
173
183
  <ul className="dpc-cards">
174
184
  {repos.map((repo) => (
175
- <li className="dpc-card" key={repo.id}>
185
+ <li className="dpc-card" key={repo.id} onClick={() => setDetail({ kind: 'skills', id: repo.id })}>
176
186
  <div className="dpc-cardTop">
177
187
  <strong className="dpc-cardTitle" title={repo.url}>{title(repo)}</strong>
178
188
  {repo.skillCount !== undefined && <span className="dpc-tag">{t('marketSkillCount').replace('{n}', String(repo.skillCount))}</span>}
@@ -180,14 +190,15 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
180
190
  </div>
181
191
  <p className="dpc-cardDesc">{desc(repo)}</p>
182
192
  <div className="dpc-cardRow">
183
- <a className="dpc-link" href={repo.homepage ?? repo.url} target="_blank" rel="noreferrer">{t('marketHome')}</a>
193
+ <button type="button" className="dpc-link" onClick={() => setDetail({ kind: 'skills', id: repo.id })}>{t('marketDetail')}</button>
194
+ <a className="dpc-link" href={repo.homepage ?? repo.url} target="_blank" rel="noreferrer" onClick={cardStop}>{t('marketHome')}</a>
184
195
  <span className="dpc-spacer" />
185
196
  {repo.installedId !== null ? (
186
- <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={() => setConfirmUninstall({ kind: 'root', id: repo.installedId as string, name: title(repo) })}>
197
+ <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={(event) => { event.stopPropagation(); setConfirmUninstall({ kind: 'root', id: repo.installedId as string, name: title(repo) }) }}>
187
198
  {t('marketUninstall')}
188
199
  </Button>
189
200
  ) : (
190
- <Button variant="primary" size="sm" disabled={busyId !== null} onClick={() => void doInstallRepo(repo)}>
201
+ <Button variant="primary" size="sm" disabled={busyId !== null} onClick={(event) => { event.stopPropagation(); void doInstallRepo(repo) }}>
191
202
  {busyId === repo.id ? t('marketInstalling') : t('marketInstall')}
192
203
  </Button>
193
204
  )}
@@ -207,7 +218,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
207
218
  {servers !== null && servers.length > 0 && (
208
219
  <ul className="dpc-cards">
209
220
  {servers.map((server) => (
210
- <li className="dpc-card" key={server.id}>
221
+ <li className="dpc-card" key={server.id} onClick={() => setDetail({ kind: 'mcp', id: server.id })}>
211
222
  <div className="dpc-cardTop">
212
223
  <strong className="dpc-cardTitle" title={server.id}>{title(server)}</strong>
213
224
  <span className="dpc-tag">{server.transport}</span>
@@ -219,20 +230,21 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
219
230
  <p className="dpc-envHint">{t('marketNeedsEnv')}: {server.envKeys.join(', ')}</p>
220
231
  )}
221
232
  <div className="dpc-cardRow">
222
- <a className="dpc-link" href={server.homepage} target="_blank" rel="noreferrer">{t('marketHome')}</a>
233
+ <button type="button" className="dpc-link" onClick={() => setDetail({ kind: 'mcp', id: server.id })}>{t('marketDetail')}</button>
234
+ <a className="dpc-link" href={server.homepage} target="_blank" rel="noreferrer" onClick={cardStop}>{t('marketHome')}</a>
223
235
  <span className="dpc-spacer" />
224
236
  {server.installed ? (
225
- <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={() => void mcp.list().then(
237
+ <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={(event) => { event.stopPropagation(); void mcp.list().then(
226
238
  (body) => {
227
239
  const row = body.servers.find(item => item.serverName === server.id)
228
240
  if (row !== undefined) setConfirmUninstall({ kind: 'mcp', id: row.id, name: server.id })
229
241
  },
230
242
  (error: Error) => setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }),
231
- )}>
243
+ ) }}>
232
244
  {t('marketUninstall')}
233
245
  </Button>
234
246
  ) : (
235
- <Button variant="primary" size="sm" disabled={busyId !== null} onClick={() => void doInstallMcp(server)}>
247
+ <Button variant="primary" size="sm" disabled={busyId !== null} onClick={(event) => { event.stopPropagation(); void doInstallMcp(server) }}>
236
248
  {busyId === server.id ? t('marketInstalling') : t('marketAdd')}
237
249
  </Button>
238
250
  )}
@@ -244,6 +256,101 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
244
256
  </>
245
257
  )}
246
258
 
259
+ <Modal
260
+ open={detailRepo !== null || detailServer !== null}
261
+ onClose={() => setDetail(null)}
262
+ title={detailRepo !== null ? title(detailRepo) : detailServer !== null ? title(detailServer) : ''}
263
+ footer={
264
+ <>
265
+ {detailRepo !== null && (
266
+ <a className="dpc-link" href={detailRepo.homepage ?? detailRepo.url} target="_blank" rel="noreferrer">{t('marketHome')}</a>
267
+ )}
268
+ {detailServer !== null && (
269
+ <a className="dpc-link" href={detailServer.homepage} target="_blank" rel="noreferrer">{t('marketHome')}</a>
270
+ )}
271
+ <span className="dpc-spacer" />
272
+ {detailRepo !== null && (detailRepo.installedId !== null ? (
273
+ <Button variant="ghost" disabled={busyId !== null} onClick={() => { setConfirmUninstall({ kind: 'root', id: detailRepo.installedId as string, name: title(detailRepo) }); setDetail(null) }}>
274
+ {t('marketUninstall')}
275
+ </Button>
276
+ ) : (
277
+ <Button variant="primary" disabled={busyId !== null} onClick={() => void doInstallRepo(detailRepo)}>
278
+ {busyId === detailRepo.id ? t('marketInstalling') : t('marketInstall')}
279
+ </Button>
280
+ ))}
281
+ {detailServer !== null && (detailServer.installed ? (
282
+ <Button variant="ghost" disabled={busyId !== null} onClick={() => void mcp.list().then(
283
+ (body) => {
284
+ const row = body.servers.find(item => item.serverName === detailServer.id)
285
+ if (row !== undefined) { setConfirmUninstall({ kind: 'mcp', id: row.id, name: title(detailServer) }); setDetail(null) }
286
+ },
287
+ (error: Error) => setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }),
288
+ )}>
289
+ {t('marketUninstall')}
290
+ </Button>
291
+ ) : (
292
+ <Button variant="primary" disabled={busyId !== null} onClick={() => void doInstallMcp(detailServer)}>
293
+ {busyId === detailServer.id ? t('marketInstalling') : t('marketAdd')}
294
+ </Button>
295
+ ))}
296
+ </>
297
+ }
298
+ >
299
+ {detailRepo !== null && (
300
+ <>
301
+ <p className="dpc-detailDesc">{longText(detailRepo)}</p>
302
+ {detailRepo.skills !== undefined && detailRepo.skills.length > 0 && (
303
+ <div className="dpc-detailSection">
304
+ <div className="dpc-detailLabel">{t('marketSkillListLabel')}</div>
305
+ <ul className="dpc-skillList">
306
+ {detailRepo.skills.map(item => (
307
+ <li className="dpc-skillItem" key={item.name}>
308
+ <code className="dpc-skillName">{item.name}</code>
309
+ {item.description !== undefined && (
310
+ <span className="dpc-skillDesc">{pick(item.description, item.descriptionZh)}</span>
311
+ )}
312
+ </li>
313
+ ))}
314
+ </ul>
315
+ </div>
316
+ )}
317
+ </>
318
+ )}
319
+ {detailServer !== null && (
320
+ <>
321
+ <p className="dpc-detailDesc">{longText(detailServer)}</p>
322
+ {detailServer.command !== undefined && (
323
+ <div className="dpc-detailSection">
324
+ <div className="dpc-detailLabel">{t('marketCommandLabel')}</div>
325
+ <code className="dpc-code">{[detailServer.command, ...(detailServer.args ?? [])].join(' ')}</code>
326
+ </div>
327
+ )}
328
+ {detailServer.transport === 'streamable-http' && detailServer.url !== undefined && (
329
+ <div className="dpc-detailSection">
330
+ <div className="dpc-detailLabel">{t('marketCommandLabel')}</div>
331
+ <code className="dpc-code">{detailServer.url}</code>
332
+ </div>
333
+ )}
334
+ {detailServer.envKeys !== undefined && detailServer.envKeys.length > 0 && (
335
+ <div className="dpc-detailSection">
336
+ <div className="dpc-detailLabel">{t('marketNeedsEnv')}</div>
337
+ <div className="dpc-detailTags">
338
+ {detailServer.envKeys.map(key => <span className="dpc-tag" key={key}>{key}</span>)}
339
+ </div>
340
+ </div>
341
+ )}
342
+ {detailServer.tools !== undefined && detailServer.tools.length > 0 && (
343
+ <div className="dpc-detailSection">
344
+ <div className="dpc-detailLabel">{t('marketToolsLabel')}</div>
345
+ <div className="dpc-detailTags">
346
+ {detailServer.tools.map(name => <span className="dpc-tag" key={name}>{name}</span>)}
347
+ </div>
348
+ </div>
349
+ )}
350
+ </>
351
+ )}
352
+ </Modal>
353
+
247
354
  <Modal
248
355
  open={confirmUninstall !== null}
249
356
  onClose={() => setConfirmUninstall(null)}
package/src/client/css.ts CHANGED
@@ -26,7 +26,7 @@ export const CSS = `
26
26
  .dpc-refresh:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}
27
27
  .dpc-empty{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary)}
28
28
  .dpc-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:10px;margin:0;padding:0;list-style:none}
29
- .dpc-card{display:flex;flex-direction:column;gap:8px;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3);padding:12px 14px}
29
+ .dpc-card{display:flex;flex-direction:column;gap:8px;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3);padding:12px 14px;cursor:pointer}
30
30
  .dpc-card:hover{background:var(--dsw-alias-interactive-bg-hover)}
31
31
  .dpc-cardTop{display:flex;align-items:center;gap:8px}
32
32
  .dpc-cardTitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px;font-weight:600;font-family:var(--ds-font-family-code)}
@@ -108,5 +108,14 @@ export const CSS = `
108
108
  .dpc-format .dpc-form{margin-top:8px}
109
109
  .dpc-formatHint{margin:2px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
110
110
  .dpc-code{margin:4px 0 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:8px 10px;overflow-x:auto;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font-family:var(--ds-font-family-code);font-size:11px;line-height:17px;white-space:pre}
111
+ /* Market detail modal. */
112
+ .dpc-detailDesc{margin:0;font-size:13px;line-height:20px}
113
+ .dpc-detailSection{margin-top:12px;display:flex;flex-direction:column;gap:6px}
114
+ .dpc-detailLabel{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}
115
+ .dpc-detailTags{display:flex;flex-wrap:wrap;gap:4px}
116
+ .dpc-skillList{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}
117
+ .dpc-skillItem{display:flex;gap:8px;align-items:baseline;font-size:12px;line-height:18px;min-width:0}
118
+ .dpc-skillName{font-family:var(--ds-font-family-code);color:var(--dsw-alias-label-primary);white-space:nowrap;flex:none}
119
+ .dpc-skillDesc{color:var(--dsw-alias-label-secondary);min-width:0}
111
120
  @media(max-width:680px){.dpc-cards{grid-template-columns:minmax(0,1fr)}}
112
121
  `
@@ -131,6 +131,13 @@ export function apply(ctx: CapabilitiesClientContext): void {
131
131
  })
132
132
  }
133
133
 
134
+ /** One skill inside a market repository's inventory, as the browser sees it. */
135
+ export interface MarketSkillItemView {
136
+ name: string
137
+ description?: string
138
+ descriptionZh?: string
139
+ }
140
+
134
141
  /** One skills-market repository as the browser sees it. */
135
142
  export interface MarketRepoView {
136
143
  id: string
@@ -138,9 +145,12 @@ export interface MarketRepoView {
138
145
  nameZh?: string
139
146
  description: string
140
147
  descriptionZh?: string
148
+ detail?: string
149
+ detailZh?: string
141
150
  url: string
142
151
  homepage?: string
143
152
  skillCount?: number
153
+ skills?: MarketSkillItemView[]
144
154
  }
145
155
 
146
156
  /** One MCP-market server as the browser sees it. */
@@ -158,4 +168,7 @@ export interface MarketServerView {
158
168
  homepage: string
159
169
  category?: string
160
170
  runtime?: string
171
+ tools?: string[]
172
+ detail?: string
173
+ detailZh?: string
161
174
  }
@@ -125,6 +125,10 @@ export const zh = {
125
125
  marketRepoInstalled: '仓库已安装,技能稍候出现在「技能」页列表。',
126
126
  marketUninstallConfirm: '确认移除?',
127
127
  marketUninstallRootWarn: '将取消扫描该仓库并删除下载的副本(移除后可重新安装)。',
128
+ marketDetail: '详情',
129
+ marketSkillListLabel: '技能清单',
130
+ marketToolsLabel: '提供工具',
131
+ marketCommandLabel: '启动命令',
128
132
  }
129
133
 
130
134
  export const en = {
@@ -252,4 +256,8 @@ export const en = {
252
256
  marketRepoInstalled: 'Repository installed — its skills appear on the Skills tab shortly.',
253
257
  marketUninstallConfirm: 'Uninstall?',
254
258
  marketUninstallRootWarn: 'Stops scanning the repository and deletes the downloaded copy (you can reinstall later).',
259
+ marketDetail: 'Details',
260
+ marketSkillListLabel: 'Skills included',
261
+ marketToolsLabel: 'Tools provided',
262
+ marketCommandLabel: 'Launch command',
255
263
  }
@@ -8,10 +8,13 @@ const okJson = (body: unknown): typeof fetch =>
8
8
  describe('loadMarketIndex', () => {
9
9
  it('prefers a valid remote index', async () => {
10
10
  const index = await loadMarketIndex('skills', {
11
- fetcher: okJson({ repos: [{ id: 'r1', name: 'Repo', description: 'd', url: 'https://github.com/a/b' }] }),
11
+ fetcher: okJson({ repos: [{ id: 'r1', name: 'Repo', description: 'd', url: 'https://github.com/a/b', detail: 'long', detailZh: '长文', skills: [{ name: 's1', description: 'first', descriptionZh: '第一个' }, { name: 's2' }, 'junk'] }] }),
12
12
  })
13
13
  expect(index?.source).toBe('remote')
14
14
  expect(index?.skills?.[0]?.id).toBe('r1')
15
+ expect(index?.skills?.[0]?.detail).toBe('long')
16
+ expect(index?.skills?.[0]?.detailZh).toBe('长文')
17
+ expect(index?.skills?.[0]?.skills).toEqual([{ name: 's1', description: 'first', descriptionZh: '第一个' }, { name: 's2' }])
15
18
  })
16
19
 
17
20
  it('falls back to the bundled snapshot when remote is unreachable or invalid', async () => {
@@ -29,7 +32,7 @@ describe('loadMarketIndex', () => {
29
32
  const index = await loadMarketIndex('mcp', {
30
33
  fetcher: okJson({
31
34
  servers: [
32
- { id: 'ok', name: 'OK', description: 'd', transport: 'stdio', command: 'npx', args: ['-y', 'x'], envKeys: ['K'], homepage: 'https://x' },
35
+ { id: 'ok', name: 'OK', description: 'd', transport: 'stdio', command: 'npx', args: ['-y', 'x'], envKeys: ['K'], homepage: 'https://x', tools: ['t1'], detail: 'long' },
33
36
  { id: 'no-command', name: 'Bad', description: 'd', transport: 'stdio', homepage: 'https://x' },
34
37
  { id: 'no-home', name: 'Bad2', description: 'd', transport: 'stdio', command: 'npx' },
35
38
  'not an object',
@@ -38,7 +41,7 @@ describe('loadMarketIndex', () => {
38
41
  })
39
42
  expect(index?.source).toBe('remote')
40
43
  expect(index?.servers).toHaveLength(1)
41
- expect(index?.servers?.[0]).toMatchObject({ id: 'ok', envKeys: ['K'] })
44
+ expect(index?.servers?.[0]).toMatchObject({ id: 'ok', envKeys: ['K'], tools: ['t1'], detail: 'long' })
42
45
  })
43
46
 
44
47
  it('ships parseable bundled snapshots with expected top-level shape', () => {
@@ -54,4 +57,12 @@ describe('loadMarketIndex', () => {
54
57
  const index = await loadMarketIndex('mcp', { fetcher: okJson({ servers: [{ id: 'http1', name: 'H', description: 'd', transport: 'streamable-http', url: 'https://e/mcp', homepage: 'https://x' }] }) })
55
58
  expect(index?.servers?.[0]).toMatchObject({ transport: 'streamable-http', url: 'https://e/mcp' })
56
59
  })
60
+
61
+ it('bundles skill and tool inventories for the market detail view', async () => {
62
+ const failing = (async () => { throw new Error('offline') }) as unknown as typeof fetch
63
+ const skills = await loadMarketIndex('skills', { fetcher: failing })
64
+ expect(skills?.skills?.some(repo => (repo.skills?.length ?? 0) > 0)).toBe(true)
65
+ const mcp = await loadMarketIndex('mcp', { fetcher: failing })
66
+ expect(mcp?.servers?.some(server => (server.tools?.length ?? 0) > 0)).toBe(true)
67
+ })
57
68
  })
package/src/market.ts CHANGED
@@ -13,6 +13,13 @@ import { fileURLToPath } from 'node:url'
13
13
  /** Which catalog a request wants. */
14
14
  export type MarketKind = 'skills' | 'mcp'
15
15
 
16
+ /** One skill inside a market repository's inventory. */
17
+ export interface MarketSkillItem {
18
+ name: string
19
+ description?: string
20
+ descriptionZh?: string
21
+ }
22
+
16
23
  /** One installable skill repository in the skills index. */
17
24
  export interface MarketSkillRepo {
18
25
  id: string
@@ -20,9 +27,14 @@ export interface MarketSkillRepo {
20
27
  nameZh?: string
21
28
  description: string
22
29
  descriptionZh?: string
30
+ /** Long-form intro for the detail view (falls back to description). */
31
+ detail?: string
32
+ detailZh?: string
23
33
  url: string
24
34
  homepage?: string
25
35
  skillCount?: number
36
+ /** Skill inventory for the market detail view. */
37
+ skills?: MarketSkillItem[]
26
38
  }
27
39
 
28
40
  /** One installable server in the MCP index. */
@@ -41,6 +53,11 @@ export interface MarketMcpServer {
41
53
  category?: string
42
54
  /** Runtime hint shown next to stdio commands (npx / uvx). */
43
55
  runtime?: string
56
+ /** Tool names the server exposes, for the market detail view. */
57
+ tools?: string[]
58
+ /** Long-form intro for the detail view (falls back to description). */
59
+ detail?: string
60
+ detailZh?: string
44
61
  }
45
62
 
46
63
  export interface MarketIndex {
@@ -62,6 +79,22 @@ export function bundledMarketPath(kind: MarketKind): string {
62
79
  const isString = (value: unknown): value is string => typeof value === 'string' && value !== ''
63
80
  const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString)
64
81
 
82
+ function parseSkillItems(value: unknown): MarketSkillItem[] | null {
83
+ if (!Array.isArray(value)) return null
84
+ const out: MarketSkillItem[] = []
85
+ for (const item of value) {
86
+ if (typeof item !== 'object' || item === null) continue
87
+ const record = item as Record<string, unknown>
88
+ if (!isString(record.name)) continue
89
+ out.push({
90
+ name: record.name,
91
+ ...isString(record.description) ? { description: record.description } : {},
92
+ ...isString(record.descriptionZh) ? { descriptionZh: record.descriptionZh } : {},
93
+ })
94
+ }
95
+ return out.length > 0 ? out : null
96
+ }
97
+
65
98
  function parseSkillsIndex(parsed: unknown): MarketSkillRepo[] | null {
66
99
  if (typeof parsed !== 'object' || parsed === null) return null
67
100
  const repos = (parsed as { repos?: unknown }).repos
@@ -71,15 +104,19 @@ function parseSkillsIndex(parsed: unknown): MarketSkillRepo[] | null {
71
104
  if (typeof entry !== 'object' || entry === null) continue
72
105
  const record = entry as Record<string, unknown>
73
106
  if (!isString(record.id) || !isString(record.name) || !isString(record.description) || !isString(record.url)) continue
107
+ const skills = parseSkillItems(record.skills)
74
108
  out.push({
75
109
  id: record.id,
76
110
  name: record.name,
77
111
  ...isString(record.nameZh) ? { nameZh: record.nameZh } : {},
78
112
  description: record.description,
79
113
  ...isString(record.descriptionZh) ? { descriptionZh: record.descriptionZh } : {},
114
+ ...isString(record.detail) ? { detail: record.detail } : {},
115
+ ...isString(record.detailZh) ? { detailZh: record.detailZh } : {},
80
116
  url: record.url,
81
117
  ...isString(record.homepage) ? { homepage: record.homepage } : {},
82
118
  ...(typeof record.skillCount === 'number' ? { skillCount: record.skillCount } : {}),
119
+ ...(skills !== null ? { skills } : {}),
83
120
  })
84
121
  }
85
122
  return out.length > 0 ? out : null
@@ -111,6 +148,9 @@ function parseMcpIndex(parsed: unknown): MarketMcpServer[] | null {
111
148
  homepage: record.homepage,
112
149
  ...isString(record.category) ? { category: record.category } : {},
113
150
  ...isString(record.runtime) ? { runtime: record.runtime } : {},
151
+ ...isStringArray(record.tools) ? { tools: record.tools } : {},
152
+ ...isString(record.detail) ? { detail: record.detail } : {},
153
+ ...isString(record.detailZh) ? { detailZh: record.detailZh } : {},
114
154
  })
115
155
  }
116
156
  return out.length > 0 ? out : null