dsh-mcp 1.3.0 → 1.5.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,254 @@
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: true, 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
+ // 展示 process.env 优先返回的值(非 secret);secret 值不展示
72
+ value: row.secret ? '' : (row.value ?? ''),
73
+ configured: row.configured,
74
+ })))
75
+ setLoaded(true)
76
+ setError(null)
77
+ },
78
+ (error: unknown) => {
79
+ if (seq !== seqRef.current) return
80
+ setLoadFailed(true)
81
+ setError(error instanceof Error ? error.message : String(error))
82
+ },
83
+ )
84
+ }
85
+
86
+ useEffect(() => {
87
+ if (loaded) return
88
+ reload()
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ }, [injected, loaded])
91
+
92
+ const update = (key: string, patch: Partial<EnvRowDraft>): void => {
93
+ setRows(prev => prev.map(row => row.key === key ? { ...row, ...patch } : row))
94
+ }
95
+ const remove = (key: string): void => {
96
+ setRows(prev => prev.filter(row => row.key !== key))
97
+ }
98
+ const add = (): void => {
99
+ setRows(prev => [...prev, emptyRow()])
100
+ }
101
+
102
+ /** Parse "NAME=value" lines (one per line) into fresh draft rows. */
103
+ const applyBulk = (): void => {
104
+ const parsed = bulkText.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0)
105
+ if (parsed.length === 0) {
106
+ setBulkOpen(false)
107
+ return
108
+ }
109
+ const next: EnvRowDraft[] = []
110
+ for (const line of parsed) {
111
+ const eq = line.indexOf('=')
112
+ if (eq <= 0) {
113
+ next.push({ key: nextKey(), name: line, secret: false, value: '', configured: false })
114
+ continue
115
+ }
116
+ const name = line.slice(0, eq).trim()
117
+ const value = line.slice(eq + 1)
118
+ if (name.length === 0) continue
119
+ next.push({ key: nextKey(), name, secret: false, value, configured: false })
120
+ }
121
+ if (next.length > 0) setRows(prev => [...prev, ...next])
122
+ setBulkOpen(false)
123
+ setBulkText('')
124
+ }
125
+
126
+ const save = (): void => {
127
+ const vars: Array<{ name: string; secret?: boolean; value?: string }> = []
128
+ const seen = new Set<string>()
129
+ for (const row of rows) {
130
+ const name = row.name.trim()
131
+ if (name.length === 0) {
132
+ if (row.value.trim().length > 0) {
133
+ setError('存在未填写变量名的行')
134
+ return
135
+ }
136
+ continue // 空行跳过
137
+ }
138
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
139
+ setError(`变量名 "${name}" 必须是字母/数字/下划线,且以字母或下划线开头`)
140
+ return
141
+ }
142
+ if (seen.has(name)) {
143
+ setError(`变量名 "${name}" 重复`)
144
+ return
145
+ }
146
+ seen.add(name)
147
+ vars.push({
148
+ name,
149
+ secret: row.secret,
150
+ // 填了值就提交(secret 也会写入凭据文档);留空不提交(secret 保留原值)
151
+ ...(row.value.trim().length > 0 ? { value: row.value } : {}),
152
+ })
153
+ }
154
+ const seq = ++seqRef.current
155
+ setBusy(true)
156
+ setError(null)
157
+ setNotice(null)
158
+ void injected.envSet(vars).then(
159
+ (state) => {
160
+ if (seq !== seqRef.current) return
161
+ setRows(state.vars.map(row => ({
162
+ key: nextKey(),
163
+ name: row.name,
164
+ secret: row.secret,
165
+ value: row.secret ? '' : (row.value ?? ''),
166
+ configured: row.configured,
167
+ })))
168
+ setNotice(t('globalEnvDone'))
169
+ onApplied()
170
+ },
171
+ (error: unknown) => {
172
+ if (seq !== seqRef.current) return
173
+ setError(error instanceof Error ? error.message : String(error))
174
+ },
175
+ ).finally(() => {
176
+ if (seq === seqRef.current) setBusy(false)
177
+ })
178
+ }
179
+
180
+ return (
181
+ <div className={css.panel}>
182
+ <p className={css.hint}>{t('globalEnvHint')}</p>
183
+ {rows.length === 0 && !loadFailed ? <p className={css.muted}>{t('globalEnvEmpty')}</p> : null}
184
+ {rows.map(row => (
185
+ <div key={row.key} className={css.row}>
186
+ <input
187
+ type="text"
188
+ className={css.name}
189
+ value={row.name}
190
+ placeholder={t('globalEnvName')}
191
+ aria-label={t('globalEnvName')}
192
+ onChange={(event) => update(row.key, { name: event.currentTarget.value })}
193
+ />
194
+ <input
195
+ type={row.secret ? 'password' : 'text'}
196
+ className={css.value}
197
+ value={row.value}
198
+ placeholder={row.secret && row.configured ? '••••••••' : t('globalEnvValue')}
199
+ aria-label={t('globalEnvValue')}
200
+ onChange={(event) => update(row.key, { value: event.currentTarget.value })}
201
+ />
202
+ <label className={`${css.secretToggle} ${row.secret ? css.secretOn : ''}`} title={t('globalEnvSecretHint')}>
203
+ <input
204
+ type="checkbox"
205
+ checked={row.secret}
206
+ aria-label={t('globalEnvSecret')}
207
+ onChange={(event) => update(row.key, { secret: event.currentTarget.checked })}
208
+ />
209
+ {t('globalEnvSecret')}
210
+ </label>
211
+ <button type="button" className={css.danger} aria-label={t('globalEnvRemove')} onClick={() => remove(row.key)}>
212
+ ✕
213
+ </button>
214
+ </div>
215
+ ))}
216
+ {loadFailed ? (
217
+ <div className={css.retryRow}>
218
+ <p className={css.error} role="alert">{t('globalEnvLoadFailed')}</p>
219
+ <button type="button" onClick={reload}>{t('globalEnvRetry')}</button>
220
+ </div>
221
+ ) : null}
222
+ {error !== null && !loadFailed ? <p className={css.error} role="alert">{error}</p> : null}
223
+ {notice !== null ? <p className={css.notice} role="status">{notice}</p> : null}
224
+ <div className={css.actions}>
225
+ <button type="button" disabled={busy} onClick={add}>{t('globalEnvAdd')}</button>
226
+ <button type="button" disabled={busy} onClick={() => setBulkOpen(true)}>{t('globalEnvBulk')}</button>
227
+ <button type="button" className={css.primary} disabled={busy} onClick={save}>
228
+ {busy ? t('globalEnvSaving') : t('globalEnvSave')}
229
+ </button>
230
+ </div>
231
+
232
+ {bulkOpen ? (
233
+ <div className={css.overlay} onClick={() => setBulkOpen(false)}>
234
+ <div className={css.dialog} role="dialog" aria-label={t('globalEnvBulk')} onClick={(event) => event.stopPropagation()}>
235
+ <p className={css.dialogTitle}>{t('globalEnvBulk')}</p>
236
+ <p className={css.hint}>{t('globalEnvBulkPrompt')}</p>
237
+ <textarea
238
+ className={css.bulkText}
239
+ rows={8}
240
+ autoFocus
241
+ spellCheck={false}
242
+ value={bulkText}
243
+ onChange={(event) => setBulkText(event.currentTarget.value)}
244
+ />
245
+ <div className={css.actions}>
246
+ <button type="button" onClick={() => setBulkOpen(false)}>{t('cancel')}</button>
247
+ <button type="button" className={css.primary} onClick={applyBulk}>{t('globalEnvBulkOk')}</button>
248
+ </div>
249
+ </div>
250
+ </div>
251
+ ) : null}
252
+ </div>
253
+ )
254
+ }
@@ -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
  }
@@ -110,23 +110,6 @@ button:disabled {
110
110
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);
111
111
  }
112
112
 
113
- .envBlock {
114
- display: flex;
115
- flex-direction: column;
116
- gap: 8px;
117
- border: 1px solid var(--dsw-alias-border-l2);
118
- border-radius: 10px;
119
- padding: 10px 12px 12px;
120
- margin: 0;
121
- }
122
-
123
- .envBlock legend {
124
- padding: 0 4px;
125
- font-size: 13px;
126
- line-height: 20px;
127
- font-weight: 600;
128
- }
129
-
130
113
  .hint,
131
114
  .muted {
132
115
  margin: 0;
@@ -135,36 +118,6 @@ button:disabled {
135
118
  color: var(--dsw-alias-label-tertiary);
136
119
  }
137
120
 
138
- .envRow {
139
- display: grid;
140
- grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.6fr) auto auto;
141
- align-items: center;
142
- gap: 8px;
143
- }
144
-
145
- .envRow input {
146
- box-sizing: border-box;
147
- border: 1px solid var(--dsw-alias-border-l2);
148
- border-radius: 6px;
149
- padding: 5px 8px;
150
- outline: none;
151
- background: var(--dsw-alias-bg-layer-1);
152
- color: var(--dsw-alias-label-primary);
153
- font: inherit;
154
- font-size: 13px;
155
- }
156
-
157
- .secretToggle {
158
- display: inline-flex;
159
- align-items: center;
160
- gap: 4px;
161
- color: var(--dsw-alias-label-secondary);
162
- font-size: 12px;
163
- line-height: 18px;
164
- white-space: nowrap;
165
- cursor: pointer;
166
- }
167
-
168
121
  .checkRow {
169
122
  display: inline-flex;
170
123
  align-items: center;
@@ -1,9 +1,8 @@
1
1
  import { useState, type ReactNode } from 'react'
2
2
  import type { McpManagerFailure, McpServerId, McpServerView } from './types.ts'
3
3
  import type {
4
- McpDraft, McpEnvRowDraft, McpTestOutcome,
4
+ McpDraft, McpTestOutcome,
5
5
  } from './mcp-store.ts'
6
- import { createEnvRowDraft } from './mcp-store.ts'
7
6
  import type { McpSettingsLocaleKey } from './locales.ts'
8
7
  import css from './ServerForm.module.css'
9
8
 
@@ -48,18 +47,6 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
48
47
  actions.updateDraft(patch)
49
48
  }
50
49
 
51
- const updateEnvRow = (key: string, patch: Partial<McpEnvRowDraft>): void => {
52
- update({ env: draft.env.map(row => row.key === key ? { ...row, ...patch } : row) })
53
- }
54
-
55
- const removeEnvRow = (key: string): void => {
56
- update({ env: draft.env.filter(row => row.key !== key) })
57
- }
58
-
59
- const addEnvRow = (): void => {
60
- update({ env: [...draft.env, createEnvRowDraft()] })
61
- }
62
-
63
50
  const errorText = (error: unknown): string =>
64
51
  error instanceof Error ? error.message : String(error)
65
52
 
@@ -220,47 +207,6 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
220
207
  </>
221
208
  )}
222
209
 
223
- {stdio ? (
224
- <fieldset className={css.envBlock}>
225
- <legend>{t('envVars')}</legend>
226
- <p className={css.hint}>{t('envHint')}</p>
227
- {draft.env.length === 0 ? <p className={css.muted}>{t('envVars')}: 0</p> : null}
228
- {draft.env.map(row => (
229
- <div key={row.key} className={css.envRow}>
230
- <input
231
- type="text"
232
- className={css.envName}
233
- value={row.name}
234
- placeholder={t('envName')}
235
- aria-label={t('envName')}
236
- onChange={(event) => updateEnvRow(row.key, { name: event.currentTarget.value })}
237
- />
238
- <input
239
- type={row.secret ? 'password' : 'text'}
240
- className={css.envValue}
241
- value={row.value}
242
- placeholder={row.secret && row.configured ? '••••••••' : t('envValue')}
243
- aria-label={t('envValue')}
244
- onChange={(event) => updateEnvRow(row.key, { value: event.currentTarget.value })}
245
- />
246
- <label className={css.secretToggle}>
247
- <input
248
- type="checkbox"
249
- checked={row.secret}
250
- title={t('envSecretHint')}
251
- onChange={(event) => updateEnvRow(row.key, { secret: event.currentTarget.checked })}
252
- />
253
- {t('envSecret')}
254
- </label>
255
- <button type="button" className={css.danger} aria-label={t('removeEnvVar')} onClick={() => removeEnvRow(row.key)}>
256
- ✕
257
- </button>
258
- </div>
259
- ))}
260
- <button type="button" onClick={addEnvRow}>{t('addEnvVar')}</button>
261
- </fieldset>
262
- ) : null}
263
-
264
210
  <label className={css.field}>
265
211
  <span>{t('toolCallTimeoutMs')}</span>
266
212
  <input