dsh-mcp 1.3.0 → 1.4.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.
@@ -0,0 +1,194 @@
1
+ .panel {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 8px;
5
+ border: 1px solid var(--dsw-alias-border-l2);
6
+ border-radius: 10px;
7
+ padding: 10px 14px;
8
+ background: var(--dsw-alias-bg-layer-3);
9
+ }
10
+
11
+ .hint,
12
+ .muted {
13
+ margin: 0;
14
+ font-size: 12px;
15
+ line-height: 18px;
16
+ color: var(--dsw-alias-label-tertiary);
17
+ }
18
+
19
+ .row {
20
+ display: grid;
21
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.8fr) auto auto;
22
+ align-items: center;
23
+ gap: 8px;
24
+ }
25
+
26
+ .row input {
27
+ box-sizing: border-box;
28
+ border: 1px solid var(--dsw-alias-border-l2);
29
+ border-radius: 6px;
30
+ padding: 5px 8px;
31
+ outline: none;
32
+ background: var(--dsw-alias-bg-layer-1);
33
+ color: var(--dsw-alias-label-primary);
34
+ font: inherit;
35
+ font-size: 13px;
36
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
37
+ }
38
+
39
+ .row input:focus-visible {
40
+ border-color: var(--dsw-alias-brand-primary);
41
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);
42
+ }
43
+
44
+ .name {
45
+ width: 100%;
46
+ }
47
+
48
+ .value {
49
+ width: 100%;
50
+ }
51
+
52
+ .secretToggle {
53
+ display: inline-flex;
54
+ align-items: center;
55
+ gap: 4px;
56
+ color: var(--dsw-alias-label-secondary);
57
+ font-size: 12px;
58
+ line-height: 18px;
59
+ white-space: nowrap;
60
+ cursor: pointer;
61
+ }
62
+
63
+ .danger {
64
+ border: 1px solid transparent !important;
65
+ background: transparent !important;
66
+ color: var(--dsw-alias-state-error-primary) !important;
67
+ cursor: pointer;
68
+ }
69
+
70
+ .overlay {
71
+ position: fixed;
72
+ inset: 0;
73
+ z-index: 1000;
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ background: rgba(0, 0, 0, 0.4);
78
+ }
79
+
80
+ .dialog {
81
+ display: flex;
82
+ flex-direction: column;
83
+ gap: 8px;
84
+ width: min(520px, calc(100vw - 48px));
85
+ max-height: 80vh;
86
+ border: 1px solid var(--dsw-alias-border-l2);
87
+ border-radius: 12px;
88
+ padding: 16px;
89
+ background: var(--dsw-alias-bg-overlay);
90
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
91
+ }
92
+
93
+ .dialogTitle {
94
+ margin: 0;
95
+ font-size: 15px;
96
+ line-height: 22px;
97
+ font-weight: 600;
98
+ color: var(--dsw-alias-label-primary);
99
+ }
100
+
101
+ .bulkText {
102
+ width: 100%;
103
+ box-sizing: border-box;
104
+ border: 1px solid var(--dsw-alias-border-l2);
105
+ border-radius: 8px;
106
+ padding: 8px 10px;
107
+ outline: none;
108
+ background: var(--dsw-alias-bg-layer-1);
109
+ color: var(--dsw-alias-label-primary);
110
+ font: inherit;
111
+ font-size: 12px;
112
+ line-height: 18px;
113
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
114
+ resize: vertical;
115
+ }
116
+
117
+ .bulkText:focus-visible {
118
+ border-color: var(--dsw-alias-brand-primary);
119
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);
120
+ }
121
+
122
+ .retryRow {
123
+ display: flex;
124
+ align-items: center;
125
+ gap: 10px;
126
+ }
127
+
128
+ .retryRow button {
129
+ border: 1px solid var(--dsw-alias-border-l2);
130
+ border-radius: 6px;
131
+ padding: 3px 10px;
132
+ background: transparent;
133
+ color: var(--dsw-alias-label-primary);
134
+ font: inherit;
135
+ font-size: 12px;
136
+ line-height: 18px;
137
+ cursor: pointer;
138
+ }
139
+
140
+ .retryRow button:hover {
141
+ background: var(--dsw-alias-interactive-bg-hover);
142
+ }
143
+
144
+ .error {
145
+ margin: 0;
146
+ color: var(--dsw-alias-state-error-primary);
147
+ font-size: 12px;
148
+ line-height: 18px;
149
+ word-break: break-all;
150
+ }
151
+
152
+ .notice {
153
+ margin: 0;
154
+ color: var(--dsw-alias-state-success-primary);
155
+ font-size: 12px;
156
+ line-height: 18px;
157
+ }
158
+
159
+ .actions {
160
+ display: flex;
161
+ justify-content: flex-end;
162
+ gap: 8px;
163
+ }
164
+
165
+ .actions button {
166
+ border: 1px solid var(--dsw-alias-border-l2);
167
+ border-radius: 6px;
168
+ padding: 5px 12px;
169
+ background: transparent;
170
+ color: var(--dsw-alias-label-primary);
171
+ font: inherit;
172
+ font-size: 13px;
173
+ line-height: 20px;
174
+ cursor: pointer;
175
+ }
176
+
177
+ .actions button:hover {
178
+ background: var(--dsw-alias-interactive-bg-hover);
179
+ }
180
+
181
+ .actions button:disabled {
182
+ cursor: not-allowed;
183
+ opacity: 0.55;
184
+ }
185
+
186
+ .actions .primary {
187
+ border-color: var(--dsw-alias-button-primary-fill);
188
+ background: var(--dsw-alias-button-primary-fill);
189
+ color: var(--dsw-alias-label-primary-foreground);
190
+ }
191
+
192
+ .actions .primary:hover {
193
+ background: var(--dsw-alias-button-primary-hover);
194
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Process-level environment-variable editor: a global key-value list shared
3
+ * by every MCP server's header substitution. Values are referenced from
4
+ * server headers as `${NAME}` or by bare name; secret values are stored in
5
+ * the credentials document and never shown back.
6
+ * @module dsh-mcp/client/GlobalEnvEditor
7
+ */
8
+
9
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
10
+ import type { McpEnvVarView } from './types.ts'
11
+ import type { McpSettingsLocaleKey } from './locales.ts'
12
+ import css from './GlobalEnvEditor.module.css'
13
+
14
+ /** One editable row in the editor. */
15
+ interface EnvRowDraft {
16
+ readonly key: string
17
+ name: string
18
+ secret: boolean
19
+ value: string
20
+ configured: boolean
21
+ }
22
+
23
+ /** Host Remote face required by the editor. */
24
+ export interface GlobalEnvRemote {
25
+ /** Read the process-level env rows. */
26
+ envList: () => Promise<{ vars: readonly McpEnvVarView[] }>
27
+ /** Replace the whole process-level env table. */
28
+ envSet: (vars: readonly { name: string; secret?: boolean; value?: string }[]) => Promise<{ vars: readonly McpEnvVarView[] }>
29
+ }
30
+
31
+ /** Props for the process env editor panel. */
32
+ export interface GlobalEnvEditorProps {
33
+ readonly injected: GlobalEnvRemote
34
+ readonly t: (key: McpSettingsLocaleKey) => string
35
+ /** Called after a successful save; the parent refreshes the page. */
36
+ readonly onApplied: () => void
37
+ }
38
+
39
+ let rowSeq = 0
40
+ function nextKey(): string {
41
+ rowSeq += 1
42
+ return `genv-${rowSeq}`
43
+ }
44
+
45
+ function emptyRow(): EnvRowDraft {
46
+ return { key: nextKey(), name: '', secret: false, value: '', configured: false }
47
+ }
48
+
49
+ /** Render the process-level environment-variable editor panel. */
50
+ export function GlobalEnvEditor({ injected, t, onApplied }: GlobalEnvEditorProps): ReactNode {
51
+ const [rows, setRows] = useState<EnvRowDraft[]>([])
52
+ const [error, setError] = useState<string | null>(null)
53
+ const [notice, setNotice] = useState<string | null>(null)
54
+ const [busy, setBusy] = useState(false)
55
+ const [loaded, setLoaded] = useState(false)
56
+ const [loadFailed, setLoadFailed] = useState(false)
57
+ const [bulkOpen, setBulkOpen] = useState(false)
58
+ const [bulkText, setBulkText] = useState('')
59
+ const seqRef = useRef(0)
60
+
61
+ const reload = (): void => {
62
+ const seq = ++seqRef.current
63
+ setLoadFailed(false)
64
+ void injected.envList().then(
65
+ (state) => {
66
+ if (seq !== seqRef.current) return
67
+ setRows(state.vars.map(row => ({
68
+ key: nextKey(),
69
+ name: row.name,
70
+ secret: row.secret,
71
+ value: '',
72
+ configured: row.configured,
73
+ })))
74
+ setLoaded(true)
75
+ setError(null)
76
+ },
77
+ (error: unknown) => {
78
+ if (seq !== seqRef.current) return
79
+ setLoadFailed(true)
80
+ setError(error instanceof Error ? error.message : String(error))
81
+ },
82
+ )
83
+ }
84
+
85
+ useEffect(() => {
86
+ if (loaded) return
87
+ reload()
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ }, [injected, loaded])
90
+
91
+ const update = (key: string, patch: Partial<EnvRowDraft>): void => {
92
+ setRows(prev => prev.map(row => row.key === key ? { ...row, ...patch } : row))
93
+ }
94
+ const remove = (key: string): void => {
95
+ setRows(prev => prev.filter(row => row.key !== key))
96
+ }
97
+ const add = (): void => {
98
+ setRows(prev => [...prev, emptyRow()])
99
+ }
100
+
101
+ /** Parse "NAME=value" lines (one per line) into fresh draft rows. */
102
+ const applyBulk = (): void => {
103
+ const parsed = bulkText.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0)
104
+ if (parsed.length === 0) {
105
+ setBulkOpen(false)
106
+ return
107
+ }
108
+ const next: EnvRowDraft[] = []
109
+ for (const line of parsed) {
110
+ const eq = line.indexOf('=')
111
+ if (eq <= 0) {
112
+ next.push({ key: nextKey(), name: line, secret: false, value: '', configured: false })
113
+ continue
114
+ }
115
+ const name = line.slice(0, eq).trim()
116
+ const value = line.slice(eq + 1)
117
+ if (name.length === 0) continue
118
+ next.push({ key: nextKey(), name, secret: false, value, configured: false })
119
+ }
120
+ if (next.length > 0) setRows(prev => [...prev, ...next])
121
+ setBulkOpen(false)
122
+ setBulkText('')
123
+ }
124
+
125
+ const save = (): void => {
126
+ const vars: Array<{ name: string; secret?: boolean; value?: string }> = []
127
+ const seen = new Set<string>()
128
+ for (const row of rows) {
129
+ const name = row.name.trim()
130
+ if (name.length === 0) {
131
+ if (row.value.trim().length > 0) {
132
+ setError('存在未填写变量名的行')
133
+ return
134
+ }
135
+ continue // 空行跳过
136
+ }
137
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
138
+ setError(`变量名 "${name}" 必须是字母/数字/下划线,且以字母或下划线开头`)
139
+ return
140
+ }
141
+ if (seen.has(name)) {
142
+ setError(`变量名 "${name}" 重复`)
143
+ return
144
+ }
145
+ seen.add(name)
146
+ vars.push({
147
+ name,
148
+ secret: row.secret,
149
+ ...(row.secret ? {} : row.value.trim().length > 0 ? { value: row.value } : {}),
150
+ })
151
+ }
152
+ const seq = ++seqRef.current
153
+ setBusy(true)
154
+ setError(null)
155
+ setNotice(null)
156
+ void injected.envSet(vars).then(
157
+ () => {
158
+ if (seq !== seqRef.current) return
159
+ setNotice(t('globalEnvDone'))
160
+ onApplied()
161
+ },
162
+ (error: unknown) => {
163
+ if (seq !== seqRef.current) return
164
+ setError(error instanceof Error ? error.message : String(error))
165
+ },
166
+ ).finally(() => {
167
+ if (seq === seqRef.current) setBusy(false)
168
+ })
169
+ }
170
+
171
+ return (
172
+ <div className={css.panel}>
173
+ <p className={css.hint}>{t('globalEnvHint')}</p>
174
+ {rows.length === 0 && !loadFailed ? <p className={css.muted}>{t('globalEnvEmpty')}</p> : null}
175
+ {rows.map(row => (
176
+ <div key={row.key} className={css.row}>
177
+ <input
178
+ type="text"
179
+ className={css.name}
180
+ value={row.name}
181
+ placeholder={t('globalEnvName')}
182
+ aria-label={t('globalEnvName')}
183
+ onChange={(event) => update(row.key, { name: event.currentTarget.value })}
184
+ />
185
+ <input
186
+ type={row.secret ? 'password' : 'text'}
187
+ className={css.value}
188
+ value={row.value}
189
+ placeholder={row.secret && row.configured ? '••••••••' : t('globalEnvValue')}
190
+ aria-label={t('globalEnvValue')}
191
+ onChange={(event) => update(row.key, { value: event.currentTarget.value })}
192
+ />
193
+ <label className={css.secretToggle}>
194
+ <input
195
+ type="checkbox"
196
+ checked={row.secret}
197
+ title={t('globalEnvSecretHint')}
198
+ onChange={(event) => update(row.key, { secret: event.currentTarget.checked })}
199
+ />
200
+ {t('globalEnvSecret')}
201
+ </label>
202
+ <button type="button" className={css.danger} aria-label={t('globalEnvRemove')} onClick={() => remove(row.key)}>
203
+ ✕
204
+ </button>
205
+ </div>
206
+ ))}
207
+ {loadFailed ? (
208
+ <div className={css.retryRow}>
209
+ <p className={css.error} role="alert">{t('globalEnvLoadFailed')}</p>
210
+ <button type="button" onClick={reload}>{t('globalEnvRetry')}</button>
211
+ </div>
212
+ ) : null}
213
+ {error !== null && !loadFailed ? <p className={css.error} role="alert">{error}</p> : null}
214
+ {notice !== null ? <p className={css.notice} role="status">{notice}</p> : null}
215
+ <div className={css.actions}>
216
+ <button type="button" disabled={busy} onClick={add}>{t('globalEnvAdd')}</button>
217
+ <button type="button" disabled={busy} onClick={() => setBulkOpen(true)}>{t('globalEnvBulk')}</button>
218
+ <button type="button" className={css.primary} disabled={busy} onClick={save}>
219
+ {busy ? t('globalEnvSaving') : t('globalEnvSave')}
220
+ </button>
221
+ </div>
222
+
223
+ {bulkOpen ? (
224
+ <div className={css.overlay} onClick={() => setBulkOpen(false)}>
225
+ <div className={css.dialog} role="dialog" aria-label={t('globalEnvBulk')} onClick={(event) => event.stopPropagation()}>
226
+ <p className={css.dialogTitle}>{t('globalEnvBulk')}</p>
227
+ <p className={css.hint}>{t('globalEnvBulkPrompt')}</p>
228
+ <textarea
229
+ className={css.bulkText}
230
+ rows={8}
231
+ autoFocus
232
+ spellCheck={false}
233
+ value={bulkText}
234
+ onChange={(event) => setBulkText(event.currentTarget.value)}
235
+ />
236
+ <div className={css.actions}>
237
+ <button type="button" onClick={() => setBulkOpen(false)}>{t('cancel')}</button>
238
+ <button type="button" className={css.primary} onClick={applyBulk}>{t('globalEnvBulkOk')}</button>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ ) : null}
243
+ </div>
244
+ )
245
+ }
@@ -68,6 +68,73 @@ button:disabled {
68
68
  background: var(--dsw-alias-button-primary-hover);
69
69
  }
70
70
 
71
+ .active {
72
+ border-color: var(--dsw-alias-brand-primary);
73
+ color: var(--dsw-alias-brand-primary);
74
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent);
75
+ }
76
+
77
+ .module {
78
+ display: flex;
79
+ flex-direction: column;
80
+ gap: 8px;
81
+ border: 1px solid var(--dsw-alias-border-l2);
82
+ border-radius: 10px;
83
+ padding: 10px 14px;
84
+ background: var(--dsw-alias-bg-layer-3);
85
+ }
86
+
87
+ .moduleTitle {
88
+ display: inline-flex !important;
89
+ align-items: center;
90
+ gap: 6px;
91
+ width: fit-content;
92
+ border: none !important;
93
+ background: transparent !important;
94
+ padding: 0 !important;
95
+ font-weight: 600;
96
+ font-size: 14px;
97
+ line-height: 20px;
98
+ color: var(--dsw-alias-label-primary) !important;
99
+ }
100
+
101
+ .moduleTitle:hover {
102
+ background: transparent !important;
103
+ }
104
+
105
+ .caret {
106
+ display: inline-block;
107
+ width: 14px;
108
+ color: var(--dsw-alias-label-tertiary);
109
+ }
110
+
111
+ .configHeader {
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: space-between;
115
+ gap: 12px;
116
+ }
117
+
118
+ .configActions {
119
+ display: flex;
120
+ gap: 8px;
121
+ }
122
+
123
+ .inlineForm {
124
+ border: 1px solid var(--dsw-alias-border-l2);
125
+ border-radius: 10px;
126
+ padding: 12px 14px;
127
+ background: var(--dsw-alias-bg-layer-2);
128
+ }
129
+
130
+ .inlineFormItem {
131
+ border: 1px solid var(--dsw-alias-border-l2);
132
+ border-radius: 10px;
133
+ padding: 12px 14px;
134
+ background: var(--dsw-alias-bg-layer-2);
135
+ list-style: none;
136
+ }
137
+
71
138
  .list {
72
139
  display: flex;
73
140
  flex-direction: column;
@@ -5,13 +5,15 @@ import type {
5
5
  } from './types.ts'
6
6
  import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
7
7
  import { McpServerForm, type McpServerFormRemote } from './ServerForm.tsx'
8
+ import { ServersJsonEditor, type ServersJsonEntry } from './ServersJsonEditor.tsx'
9
+ import { GlobalEnvEditor, type GlobalEnvRemote } from './GlobalEnvEditor.tsx'
8
10
  import type { McpToolControlRemote } from './ToolControlSection.tsx'
9
11
  import type { McpSettingsLocaleKey } from './locales.ts'
10
12
  import { createMcpManagerStore, type McpDraft, type McpTestOutcome } from './mcp-store.ts'
11
13
  import css from './McpSettingsSection.module.css'
12
14
 
13
15
  /** Registration-side Remote face used by the section. */
14
- export interface McpManagerInjected extends McpServerFormRemote, McpToolControlRemote {
16
+ export interface McpManagerInjected extends McpServerFormRemote, McpToolControlRemote, GlobalEnvRemote {
15
17
  /** Read the current server list. */
16
18
  list: () => Promise<readonly McpServerView[]>
17
19
  /** Persist one draft; null means success, a failure is otherwise returned. */
@@ -20,6 +22,8 @@ export interface McpManagerInjected extends McpServerFormRemote, McpToolControlR
20
22
  remove: (id: McpServerId) => Promise<McpManagerFailure | null>
21
23
  /** Probe one draft; `draft.id` lets stored secret values resolve. */
22
24
  test: (draft: McpDraft) => Promise<McpTestOutcome>
25
+ /** Replace the whole server list (JSON editor path). */
26
+ upsertJson: (servers: readonly ServersJsonEntry[]) => Promise<{ added: number; updated: number; removed: number }>
23
27
  }
24
28
 
25
29
  /** Full component props assembled by the Settings slot renderer. */
@@ -58,13 +62,6 @@ function viewToDraft(server: McpServerView): McpDraft {
58
62
  cwd: server.cwd,
59
63
  url: server.url,
60
64
  headersText: server.headers.map(header => `${header.name}: ${header.value}`).join('\n'),
61
- env: server.env.map((entry, index) => ({
62
- key: `view-${index}`,
63
- name: entry.name,
64
- secret: entry.secret,
65
- value: '',
66
- configured: entry.configured,
67
- })),
68
65
  toolCallTimeoutMs: String(server.toolCallTimeoutMs),
69
66
  failOnStartupError: server.failOnStartupError,
70
67
  }
@@ -86,7 +83,10 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
86
83
  const [toolsError, setToolsError] = useState<string | null>(null)
87
84
  const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set())
88
85
  const [refreshing, setRefreshing] = useState<ReadonlySet<string>>(new Set())
86
+ const [jsonOpen, setJsonOpen] = useState(false)
87
+ const [envOpen, setEnvOpen] = useState(true)
89
88
  const timersRef = useRef<number[]>([])
89
+ let inlineForm: ReactNode | null = null
90
90
 
91
91
  useEffect(() => () => {
92
92
  for (const id of timersRef.current) window.clearTimeout(id)
@@ -239,7 +239,7 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
239
239
  setTest,
240
240
  setServers,
241
241
  }
242
- return (
242
+ const form = (
243
243
  <McpServerForm
244
244
  draft={state.draft}
245
245
  busy={state.busy}
@@ -258,14 +258,11 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
258
258
  }}
259
259
  />
260
260
  )
261
+ inlineForm = form
261
262
  }
262
263
 
263
264
  return (
264
265
  <div className={css.section}>
265
- <div className={css.header}>
266
- <button type="button" className={css.primary} onClick={beginCreate}>{t('addServer')}</button>
267
- </div>
268
-
269
266
  <div className={css.modeRow}>
270
267
  <span className={css.modeLabel}>{t('toolsModeLabel')}</span>
271
268
  <label className={css.modeOption}>
@@ -291,15 +288,75 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
291
288
  {toolsError !== null ? toolsError : tools === null ? t('toolsLoading') : mode === 'search' ? t('toolsHintSearch') : t('toolsHintFull')}
292
289
  </p>
293
290
 
294
- {state.servers.length === 0 ? (
295
- <p className={css.status}>{t('empty')}</p>
296
- ) : (
291
+ <div className={css.module}>
292
+ <button
293
+ type="button"
294
+ className={css.moduleTitle}
295
+ aria-expanded={envOpen}
296
+ onClick={() => setEnvOpen(open => !open)}
297
+ >
298
+ <span className={css.caret}>{envOpen ? '▾' : '▸'}</span>
299
+ {t('globalEnv')}
300
+ </button>
301
+ {envOpen ? (
302
+ <GlobalEnvEditor
303
+ injected={{ envList: props.envList, envSet: props.envSet }}
304
+ t={t}
305
+ onApplied={() => {
306
+ // New process env may feed header substitution: refresh server
307
+ // rows (connection attempts re-resolve env) but keep the tool list.
308
+ refreshServer('')
309
+ }}
310
+ />
311
+ ) : null}
312
+ </div>
313
+
314
+ <div className={css.module}>
315
+ <div className={css.configHeader}>
316
+ <span className={css.moduleTitle}>{t('configTitle')}</span>
317
+ <div className={css.configActions}>
318
+ <button
319
+ type="button"
320
+ className={jsonOpen ? css.active : undefined}
321
+ aria-expanded={jsonOpen}
322
+ onClick={() => setJsonOpen(open => !open)}
323
+ >
324
+ {t('serversJson')}
325
+ </button>
326
+ <button type="button" className={css.primary} onClick={beginCreate}>{t('addServer')}</button>
327
+ </div>
328
+ </div>
329
+ {jsonOpen ? (
330
+ <ServersJsonEditor
331
+ injected={{ list: props.list, upsertJson: props.upsertJson }}
332
+ t={t}
333
+ onApplied={() => {
334
+ // The whole list changed on the Host: refresh the server rows and
335
+ // the tool list, then close the JSON panel to show the UI list.
336
+ refreshServer('')
337
+ refreshTools()
338
+ setJsonOpen(false)
339
+ // Mounting is asynchronous: refresh again after the connection
340
+ // settles so badges/tool counts reflect the live state.
341
+ timersRef.current.push(window.setTimeout(() => refreshServer(''), 2000))
342
+ timersRef.current.push(window.setTimeout(() => refreshServer(''), 6000))
343
+ }}
344
+ />
345
+ ) : null}
346
+
347
+ {state.draft !== null && state.draft.id === null ? (
348
+ <div className={css.inlineForm}>{inlineForm}</div>
349
+ ) : null}
350
+
351
+ {jsonOpen ? null : state.servers.length === 0 ? (
352
+ <p className={css.status}>{t('empty')}</p>
353
+ ) : (
297
354
  <ul className={css.list}>
298
- {state.servers.map(server => {
355
+ {state.servers.flatMap(server => {
299
356
  const isExpanded = expanded.has(server.serverName)
300
357
  const isRefreshing = refreshing.has(server.serverName)
301
358
  const serverToolList = serverTools(server.serverName)
302
- return (
359
+ const card = (
303
360
  <li key={server.id} className={css.card}>
304
361
  <div className={css.row}>
305
362
  <div className={css.rowMain}>
@@ -351,9 +408,16 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
351
408
  ) : null}
352
409
  </li>
353
410
  )
411
+ // Editing this server: expand the form right below its row so the
412
+ // user never has to scroll away to find it.
413
+ if (state.draft !== null && state.draft.id === server.id) {
414
+ return [card, <li key={`${server.id}-form`} className={css.inlineFormItem}>{inlineForm}</li>]
415
+ }
416
+ return [card]
354
417
  })}
355
418
  </ul>
356
419
  )}
420
+ </div>
357
421
  </div>
358
422
  )
359
423
  }