dsh-mcp 1.2.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.
@@ -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,16 +1,15 @@
1
1
  import { useState, type ReactNode } from 'react'
2
- import type { McpServerId, McpServerView } from './types.ts'
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, failureLocaleKey } from './mcp-store.ts'
7
6
  import type { McpSettingsLocaleKey } from './locales.ts'
8
7
  import css from './ServerForm.module.css'
9
8
 
10
9
  /** The Remote verbs the editor needs; structurally the section's inject face. */
11
10
  export interface McpServerFormRemote {
12
- save: (draft: McpDraft) => Promise<{ message: string } | null>
13
- remove: (id: McpServerId) => Promise<{ message: string } | null>
11
+ save: (draft: McpDraft) => Promise<McpManagerFailure | null>
12
+ remove: (id: McpServerId) => Promise<McpManagerFailure | null>
14
13
  test: (draft: McpDraft) => Promise<McpTestOutcome>
15
14
  list: () => Promise<readonly McpServerView[]>
16
15
  }
@@ -48,25 +47,18 @@ 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
- }
50
+ const errorText = (error: unknown): string =>
51
+ error instanceof Error ? error.message : String(error)
62
52
 
63
53
  const runTest = async (): Promise<void> => {
64
54
  setSaveError(null)
65
55
  actions.setTestRunning(true)
66
56
  try {
67
57
  actions.setTest(await injected.test(draft))
68
- } catch {
58
+ } catch (error) {
59
+ console.error('[dsh-mcp] test failed:', error)
69
60
  actions.setTest(null)
61
+ setSaveError(errorText(error))
70
62
  } finally {
71
63
  actions.setTestRunning(false)
72
64
  }
@@ -78,14 +70,15 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
78
70
  try {
79
71
  const failure = await injected.save(draft)
80
72
  if (failure !== null) {
81
- setSaveError(t(failureLocaleKey(failure.code)))
73
+ setSaveError(`${failure.code}: ${failure.message}`)
82
74
  return
83
75
  }
84
- await refresh()
76
+ if (!await refresh()) return
85
77
  actions.cancelEdit()
86
78
  onSaved?.()
87
- } catch {
88
- setSaveError(t('failureTitle'))
79
+ } catch (error) {
80
+ console.error('[dsh-mcp] save failed:', error)
81
+ setSaveError(errorText(error))
89
82
  } finally {
90
83
  actions.setBusy(null)
91
84
  }
@@ -98,24 +91,27 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
98
91
  try {
99
92
  const failure = await injected.remove(draft.id)
100
93
  if (failure !== null) {
101
- setSaveError(failure.message)
94
+ setSaveError(`${failure.code}: ${failure.message}`)
102
95
  return
103
96
  }
104
- await refresh()
97
+ if (!await refresh()) return
105
98
  actions.cancelEdit()
106
- } catch {
107
- setSaveError(t('failureTitle'))
99
+ } catch (error) {
100
+ console.error('[dsh-mcp] remove failed:', error)
101
+ setSaveError(errorText(error))
108
102
  } finally {
109
103
  actions.setBusy(null)
110
104
  }
111
105
  }
112
106
 
113
- const refresh = async (): Promise<void> => {
107
+ const refresh = async (): Promise<boolean> => {
114
108
  try {
115
109
  actions.setServers(await injected.list())
116
- } catch {
117
- // The save/remove outcome is the actionable one; a failed refresh is
118
- // re-run on the next page visit.
110
+ return true
111
+ } catch (error) {
112
+ console.error('[dsh-mcp] refresh failed:', error)
113
+ setSaveError(errorText(error))
114
+ return false
119
115
  }
120
116
  }
121
117
 
@@ -211,47 +207,6 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
211
207
  </>
212
208
  )}
213
209
 
214
- {stdio ? (
215
- <fieldset className={css.envBlock}>
216
- <legend>{t('envVars')}</legend>
217
- <p className={css.hint}>{t('envHint')}</p>
218
- {draft.env.length === 0 ? <p className={css.muted}>{t('envVars')}: 0</p> : null}
219
- {draft.env.map(row => (
220
- <div key={row.key} className={css.envRow}>
221
- <input
222
- type="text"
223
- className={css.envName}
224
- value={row.name}
225
- placeholder={t('envName')}
226
- aria-label={t('envName')}
227
- onChange={(event) => updateEnvRow(row.key, { name: event.currentTarget.value })}
228
- />
229
- <input
230
- type={row.secret ? 'password' : 'text'}
231
- className={css.envValue}
232
- value={row.value}
233
- placeholder={row.secret && row.configured ? '••••••••' : t('envValue')}
234
- aria-label={t('envValue')}
235
- onChange={(event) => updateEnvRow(row.key, { value: event.currentTarget.value })}
236
- />
237
- <label className={css.secretToggle}>
238
- <input
239
- type="checkbox"
240
- checked={row.secret}
241
- title={t('envSecretHint')}
242
- onChange={(event) => updateEnvRow(row.key, { secret: event.currentTarget.checked })}
243
- />
244
- {t('envSecret')}
245
- </label>
246
- <button type="button" className={css.danger} aria-label={t('removeEnvVar')} onClick={() => removeEnvRow(row.key)}>
247
- ✕
248
- </button>
249
- </div>
250
- ))}
251
- <button type="button" onClick={addEnvRow}>{t('addEnvVar')}</button>
252
- </fieldset>
253
- ) : null}
254
-
255
210
  <label className={css.field}>
256
211
  <span>{t('toolCallTimeoutMs')}</span>
257
212
  <input
@@ -0,0 +1,90 @@
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
+ .editor {
12
+ width: 100%;
13
+ box-sizing: border-box;
14
+ min-height: 260px;
15
+ border: 1px solid var(--dsw-alias-border-l2);
16
+ border-radius: 8px;
17
+ padding: 8px 10px;
18
+ outline: none;
19
+ background: var(--dsw-alias-bg-layer-1);
20
+ color: var(--dsw-alias-label-primary);
21
+ font: inherit;
22
+ font-size: 12px;
23
+ line-height: 18px;
24
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
25
+ resize: vertical;
26
+ }
27
+
28
+ .editor:focus-visible {
29
+ border-color: var(--dsw-alias-brand-primary);
30
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);
31
+ }
32
+
33
+ .hint {
34
+ margin: 0;
35
+ color: var(--dsw-alias-label-tertiary);
36
+ font-size: 12px;
37
+ line-height: 18px;
38
+ }
39
+
40
+ .error {
41
+ margin: 0;
42
+ color: var(--dsw-alias-state-error-primary);
43
+ font-size: 12px;
44
+ line-height: 18px;
45
+ word-break: break-all;
46
+ }
47
+
48
+ .notice {
49
+ margin: 0;
50
+ color: var(--dsw-alias-state-success-primary);
51
+ font-size: 12px;
52
+ line-height: 18px;
53
+ }
54
+
55
+ .actions {
56
+ display: flex;
57
+ justify-content: flex-end;
58
+ gap: 8px;
59
+ }
60
+
61
+ .actions button {
62
+ border: 1px solid var(--dsw-alias-border-l2);
63
+ border-radius: 6px;
64
+ padding: 5px 12px;
65
+ background: transparent;
66
+ color: var(--dsw-alias-label-primary);
67
+ font: inherit;
68
+ font-size: 13px;
69
+ line-height: 20px;
70
+ cursor: pointer;
71
+ }
72
+
73
+ .actions button:hover {
74
+ background: var(--dsw-alias-interactive-bg-hover);
75
+ }
76
+
77
+ .actions button:disabled {
78
+ cursor: not-allowed;
79
+ opacity: 0.55;
80
+ }
81
+
82
+ .actions .primary {
83
+ border-color: var(--dsw-alias-button-primary-fill);
84
+ background: var(--dsw-alias-button-primary-fill);
85
+ color: var(--dsw-alias-label-primary-foreground);
86
+ }
87
+
88
+ .actions .primary:hover {
89
+ background: var(--dsw-alias-button-primary-hover);
90
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * JSON server-config editor: view and edit the whole MCP server list as one
3
+ * JSON document. The document is an array of server definitions (the same
4
+ * shape the add/edit form submits); applying it replaces the whole list —
5
+ * listed servers are created or updated, existing servers absent from the
6
+ * document are removed. Secret env values are never exported (a `configured`
7
+ * flag marks them) and a blank secret value keeps the stored one.
8
+ *
9
+ * [
10
+ * {
11
+ * "serverName": "feishu-mcp",
12
+ * "transport": "streamable-http",
13
+ * "enabled": true,
14
+ * "url": "https://.../mcp",
15
+ * "headers": { "Authorization": "Bearer ..." },
16
+ * "toolCallTimeoutMs": 60000,
17
+ * "failOnStartupError": true,
18
+ * "env": []
19
+ * }
20
+ * ]
21
+ * @module dsh-mcp/client/ServersJsonEditor
22
+ */
23
+
24
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
25
+ import type { McpServerView } from './types.ts'
26
+ import type { McpSettingsLocaleKey } from './locales.ts'
27
+ import css from './ServersJsonEditor.module.css'
28
+
29
+ /** One { server, env } entry submitted to the Host's upsertJson. */
30
+ export interface ServersJsonEntry {
31
+ readonly server: Record<string, unknown>
32
+ readonly env: readonly unknown[]
33
+ }
34
+
35
+ /** Host Remote face required by the JSON editor. */
36
+ export interface ServersJsonRemote {
37
+ /** Read the current server list. */
38
+ list: () => Promise<readonly McpServerView[]>
39
+ /** Replace the whole server list; returns change counts. */
40
+ upsertJson: (servers: readonly ServersJsonEntry[]) => Promise<{ added: number; updated: number; removed: number }>
41
+ }
42
+
43
+ /** Props for the JSON editor panel. */
44
+ export interface ServersJsonEditorProps {
45
+ readonly injected: ServersJsonRemote
46
+ readonly t: (key: McpSettingsLocaleKey) => string
47
+ /** Called after a successful apply; the parent refreshes the page. */
48
+ readonly onApplied: () => void
49
+ }
50
+
51
+ /** Serialize the current server list into the JSON document text. */
52
+ export function serversToJsonText(servers: readonly McpServerView[]): string {
53
+ const document = servers.map((server) => {
54
+ const entry: Record<string, unknown> = {
55
+ serverName: server.serverName,
56
+ transport: server.transport,
57
+ enabled: server.enabled,
58
+ }
59
+ if (server.transport === 'stdio') {
60
+ entry.command = server.command
61
+ entry.args = [...server.args]
62
+ if (server.cwd.length > 0) entry.cwd = server.cwd
63
+ } else {
64
+ entry.url = server.url
65
+ }
66
+ if (server.headers.length > 0) {
67
+ entry.headers = Object.fromEntries(server.headers.map(header => [header.name, header.value]))
68
+ }
69
+ entry.toolCallTimeoutMs = server.toolCallTimeoutMs
70
+ entry.failOnStartupError = server.failOnStartupError
71
+ entry.env = server.env.map(row => row.secret
72
+ ? { name: row.name, secret: true, configured: row.configured }
73
+ : { name: row.name, secret: false, ...(row.value !== undefined && row.value.length > 0 ? { value: row.value } : {}) })
74
+ return entry
75
+ })
76
+ return `${JSON.stringify(document, null, 2)}\n`
77
+ }
78
+
79
+ /** Structural validation; deep validation (paths, URLs) happens on the Host. */
80
+ export function parseServersJson(text: string): { ok: true; servers: readonly ServersJsonEntry[] } | { ok: false; error: string } {
81
+ let value: unknown
82
+ try {
83
+ value = JSON.parse(text)
84
+ } catch (error) {
85
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
86
+ }
87
+ if (!Array.isArray(value)) {
88
+ return { ok: false, error: 'JSON 顶层必须是服务器配置数组:[ { serverName, transport, ... }, ... ]' }
89
+ }
90
+ if (value.length === 0) {
91
+ return { ok: false, error: '配置列表不能为空' }
92
+ }
93
+ const servers: ServersJsonEntry[] = []
94
+ const seen = new Set<string>()
95
+ for (const item of value) {
96
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
97
+ return { ok: false, error: '每个条目必须是对象:{ serverName, transport, ... }' }
98
+ }
99
+ const server = item as Record<string, unknown>
100
+ const serverName = typeof server.serverName === 'string' ? server.serverName.trim() : ''
101
+ if (serverName.length === 0) return { ok: false, error: 'serverName 不能为空' }
102
+ if (seen.has(serverName)) return { ok: false, error: `serverName "${serverName}" 重复` }
103
+ seen.add(serverName)
104
+ const transport = server.transport
105
+ if (transport !== 'stdio' && transport !== 'streamable-http') {
106
+ return { ok: false, error: `服务器 "${serverName}" 的 transport 必须是 stdio 或 streamable-http` }
107
+ }
108
+ if (transport === 'stdio' && (typeof server.command !== 'string' || server.command.trim().length === 0)) {
109
+ return { ok: false, error: `服务器 "${serverName}"(stdio)缺少 command` }
110
+ }
111
+ if (transport === 'streamable-http' && typeof server.url !== 'string') {
112
+ return { ok: false, error: `服务器 "${serverName}"(streamable-http)缺少 url` }
113
+ }
114
+ if (server.headers !== undefined) {
115
+ const headers = server.headers as unknown
116
+ const okHeaders = (headers !== null && typeof headers === 'object' && !Array.isArray(headers))
117
+ || (Array.isArray(headers) && headers.every(h => h !== null && typeof h === 'object' && typeof (h as { name?: unknown }).name === 'string'))
118
+ if (!okHeaders) return { ok: false, error: `服务器 "${serverName}" 的 headers 必须是 { "名称": "值" } 对象或 { name, value } 数组` }
119
+ }
120
+ const envValue = server.env
121
+ const env: unknown[] = Array.isArray(envValue) ? envValue : []
122
+ for (const row of env) {
123
+ const r = row as Record<string, unknown> | null
124
+ if (r === null || typeof r !== 'object' || typeof r.name !== 'string' || r.name.trim().length === 0) {
125
+ return { ok: false, error: `服务器 "${serverName}" 的 env 条目缺少合法的 name` }
126
+ }
127
+ if (r.secret !== undefined && typeof r.secret !== 'boolean') {
128
+ return { ok: false, error: `服务器 "${serverName}" 的 env 条目 "${r.name}" 的 secret 必须是布尔值` }
129
+ }
130
+ if (r.value !== undefined && typeof r.value !== 'string') {
131
+ return { ok: false, error: `服务器 "${serverName}" 的 env 条目 "${r.name}" 的 value 必须是字符串` }
132
+ }
133
+ }
134
+ servers.push({ server, env })
135
+ }
136
+ return { ok: true, servers }
137
+ }
138
+
139
+ /** Render the JSON server-config editor panel. */
140
+ export function ServersJsonEditor({ injected, t, onApplied }: ServersJsonEditorProps): ReactNode {
141
+ const [text, setText] = useState('')
142
+ const [error, setError] = useState<string | null>(null)
143
+ const [notice, setNotice] = useState<string | null>(null)
144
+ const [busy, setBusy] = useState(false)
145
+ const [loaded, setLoaded] = useState(false)
146
+ const seqRef = useRef(0)
147
+
148
+ // Seed the document once with the current server list.
149
+ useEffect(() => {
150
+ if (loaded) return
151
+ let current = true
152
+ void injected.list().then(
153
+ (servers) => {
154
+ if (!current) return
155
+ setText(serversToJsonText(servers))
156
+ setLoaded(true)
157
+ setError(null)
158
+ },
159
+ () => { if (current) setError('无法读取服务器列表') },
160
+ )
161
+ return () => { current = false }
162
+ }, [injected, loaded])
163
+
164
+ const reseed = (): void => {
165
+ const seq = ++seqRef.current
166
+ setBusy(true)
167
+ setNotice(null)
168
+ void injected.list().then(
169
+ (servers) => {
170
+ if (seq !== seqRef.current) return
171
+ setText(serversToJsonText(servers))
172
+ setError(null)
173
+ },
174
+ () => { if (seq === seqRef.current) setError('无法读取服务器列表') },
175
+ ).finally(() => {
176
+ if (seq === seqRef.current) setBusy(false)
177
+ })
178
+ }
179
+
180
+ const apply = (): void => {
181
+ const parsed = parseServersJson(text)
182
+ if (!parsed.ok) {
183
+ setError(parsed.error)
184
+ return
185
+ }
186
+ const seq = ++seqRef.current
187
+ setBusy(true)
188
+ setError(null)
189
+ setNotice(null)
190
+ void injected.upsertJson(parsed.servers).then(
191
+ (result) => {
192
+ if (seq !== seqRef.current) return
193
+ setNotice(`${t('serversJsonDone')}: +${result.added} / ~${result.updated} / -${result.removed}`)
194
+ onApplied()
195
+ },
196
+ (error: unknown) => {
197
+ if (seq !== seqRef.current) return
198
+ setError(error instanceof Error ? error.message : String(error))
199
+ },
200
+ ).finally(() => {
201
+ if (seq === seqRef.current) setBusy(false)
202
+ })
203
+ }
204
+
205
+ return (
206
+ <div className={css.panel}>
207
+ <textarea
208
+ className={css.editor}
209
+ rows={16}
210
+ spellCheck={false}
211
+ aria-label={t('serversJsonLabel')}
212
+ value={text}
213
+ onChange={(event) => setText(event.currentTarget.value)}
214
+ />
215
+ <p className={css.hint}>{t('serversJsonHint')}</p>
216
+ {error !== null ? <p className={css.error} role="alert">{error}</p> : null}
217
+ {notice !== null ? <p className={css.notice} role="status">{notice}</p> : null}
218
+ <div className={css.actions}>
219
+ <button type="button" disabled={busy} onClick={reseed}>{t('serversJsonReseed')}</button>
220
+ <button type="button" className={css.primary} disabled={busy} onClick={apply}>
221
+ {busy ? t('serversJsonSaving') : t('serversJsonApply')}
222
+ </button>
223
+ </div>
224
+ </div>
225
+ )
226
+ }
@@ -29,6 +29,12 @@ interface McpManagerRemote {
29
29
  readonly env: readonly unknown[]
30
30
  }): Promise<McpRemoteResult<{ readonly server: McpServerView }>>
31
31
  delete(request: { readonly id: McpServerId }): Promise<McpRemoteResult<unknown>>
32
+ upsertJson(request: { readonly servers: readonly unknown[] }): Promise<McpRemoteResult<{
33
+ readonly added: number
34
+ readonly updated: number
35
+ readonly removed: number
36
+ readonly servers: readonly McpServerView[]
37
+ }>>
32
38
  test(request: {
33
39
  readonly id?: McpServerId
34
40
  readonly server: unknown
@@ -37,11 +43,13 @@ interface McpManagerRemote {
37
43
  toolsList(): Promise<McpRemoteResult<{ readonly tools: readonly unknown[]; readonly mode: unknown; readonly hotSize: number }>>
38
44
  toolsSet(request: { readonly name: string; readonly enabled: boolean }): Promise<McpRemoteResult<{ readonly ok: boolean }>>
39
45
  toolsMode(request: { readonly mode: unknown }): Promise<McpRemoteResult<{ readonly ok: boolean }>>
46
+ envList(): Promise<McpRemoteResult<{ readonly vars: readonly unknown[] }>>
47
+ envSet(request: { readonly vars: readonly unknown[] }): Promise<McpRemoteResult<{ readonly vars: readonly unknown[] }>>
40
48
  }
41
49
 
42
50
  export type { McpSettingsSectionProps, McpManagerInjected } from './McpSettingsSection.tsx'
43
51
  export type { McpServerFormProps } from './ServerForm.tsx'
44
- export type { McpDraft, McpEnvRowDraft, McpTestOutcome } from './mcp-store.ts'
52
+ export type { McpDraft, McpTestOutcome } from './mcp-store.ts'
45
53
  export type { McpSettingsLocaleKey } from './locales.ts'
46
54
 
47
55
  declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -113,7 +121,6 @@ export async function apply(ctx: ClientContext): Promise<void> {
113
121
  const result = await manager.upsert({
114
122
  ...draft.id === null ? {} : { id: draft.id },
115
123
  server: submission.server,
116
- env: submission.env,
117
124
  })
118
125
  if (result.ok) return null
119
126
  return failureOf(result.error)
@@ -123,12 +130,15 @@ export async function apply(ctx: ClientContext): Promise<void> {
123
130
  if (result.ok) return null
124
131
  return failureOf(result.error)
125
132
  },
133
+ upsertJson: async (servers) => {
134
+ const result = await unwrap(() => manager.upsertJson({ servers }))
135
+ return { added: result.added, updated: result.updated, removed: result.removed, servers: result.servers }
136
+ },
126
137
  test: async (draft) => {
127
138
  const submission = draftToSubmission(draft)
128
139
  const result = await unwrap(() => manager.test({
129
140
  ...draft.id === null ? {} : { id: draft.id },
130
141
  server: submission.server,
131
- env: submission.env,
132
142
  }))
133
143
  return { probe: result.probe, elapsedMs: result.elapsedMs }
134
144
  },
@@ -146,6 +156,14 @@ export async function apply(ctx: ClientContext): Promise<void> {
146
156
  const result = await unwrap(() => manager.toolsMode({ mode: request.mode }))
147
157
  return { ok: result.ok }
148
158
  },
159
+ envList: async () => {
160
+ const result = await unwrap(() => manager.envList())
161
+ return { vars: result.vars }
162
+ },
163
+ envSet: async (vars) => {
164
+ const result = await unwrap(() => manager.envSet({ vars }))
165
+ return { vars: result.vars }
166
+ },
149
167
  })
150
168
 
151
169
  ctx.slots.inject('settings.section', () => ctx.slots.register({
@@ -24,9 +24,9 @@ export const zh = {
24
24
  url: '服务器 URL',
25
25
  urlPlaceholder: '例如 http://localhost:3000/mcp',
26
26
  headers: '请求头',
27
- headersPlaceholder: '每行一个 "名称: 值"',
27
+ headersPlaceholder: '每行一个 "名称: 值";值支持 ${ENV} 环境变量替换',
28
28
  envVars: '环境变量',
29
- envHint: '注入到 stdio 子进程;标记为 secret 的值写入凭据文档,不在此明文保存',
29
+ envHint: 'stdio:注入子进程;HTTP:供请求头 ${变量} 替换(如 Authorization: Bearer ${TOKEN})。标记为 secret 的值写入凭据文档,不在此明文保存',
30
30
  envName: '变量名',
31
31
  envValue: '值',
32
32
  envSecret: 'secret',
@@ -73,6 +73,31 @@ export const zh = {
73
73
  refreshing: '刷新中…',
74
74
  toolsExpand: '展开工具',
75
75
  toolsCollapse: '收起工具',
76
+ serversJson: 'JSON 维护配置',
77
+ serversJsonLabel: '服务器配置 JSON',
78
+ serversJsonHint: '整个 MCP 服务器配置列表(数组):应用后将按此列表替换——已列出的服务器创建/更新,未列出的将被删除;secret 环境变量值不会导出(configured 标记),留空则保留原值。',
79
+ serversJsonReseed: '重新导出',
80
+ serversJsonApply: '应用',
81
+ serversJsonSaving: '应用中…',
82
+ serversJsonDone: '已应用',
83
+ globalEnv: '进程环境变量',
84
+ globalEnvHint: '进程级环境变量,供所有 MCP 服务器的请求头替换引用(headers 值写 ${变量名} 或直接写变量名)。secret 值写入凭据文档,不在此明文显示;留空保留原值。',
85
+ globalEnvName: '变量名',
86
+ globalEnvValue: '值',
87
+ globalEnvSecret: 'secret',
88
+ globalEnvSecretHint: '值只写,保存后不可见;留空表示保留原值',
89
+ globalEnvAdd: '添加变量',
90
+ globalEnvBulk: '批量添加',
91
+ globalEnvBulkPrompt: '每行一个变量,格式 NAME=value;只填 NAME 的行将作为仅变量名行添加。',
92
+ globalEnvBulkOk: '添加',
93
+ globalEnvSave: '保存',
94
+ globalEnvSaving: '保存中…',
95
+ globalEnvRemove: '移除该变量',
96
+ globalEnvEmpty: '(暂无进程环境变量)',
97
+ globalEnvLoadFailed: '读取环境变量失败',
98
+ globalEnvRetry: '重试',
99
+ globalEnvDone: '已保存',
100
+ configTitle: 'MCP 配置',
76
101
  } satisfies Record<string, string>
77
102
 
78
103
  /** MCP settings locale key union. */
@@ -102,9 +127,9 @@ export const en = {
102
127
  url: 'Server URL',
103
128
  urlPlaceholder: 'e.g. http://localhost:3000/mcp',
104
129
  headers: 'Headers',
105
- headersPlaceholder: 'One "Name: value" per line',
130
+ headersPlaceholder: 'One "Name: value" per line; values support ${ENV} substitution',
106
131
  envVars: 'Environment variables',
107
- envHint: 'Injected into the stdio child; secret values are stored in the credentials document, never shown here',
132
+ envHint: 'stdio: injected into the child process; HTTP: used for ${VAR} substitution in headers (e.g. Authorization: Bearer ${TOKEN}). Secret values are stored in the credentials document, never shown here',
108
133
  envName: 'Name',
109
134
  envValue: 'Value',
110
135
  envSecret: 'secret',
@@ -151,4 +176,29 @@ export const en = {
151
176
  refreshing: 'Refreshing…',
152
177
  toolsExpand: 'Expand tools',
153
178
  toolsCollapse: 'Collapse tools',
179
+ serversJson: 'JSON config editor',
180
+ serversJsonLabel: 'Server-config JSON',
181
+ serversJsonHint: 'The whole MCP server list (array): applying replaces the list — listed servers are created/updated, existing servers absent from the document are removed; secret env values are never exported (a configured flag marks them), a blank value keeps the stored one.',
182
+ serversJsonReseed: 'Re-export',
183
+ serversJsonApply: 'Apply',
184
+ serversJsonSaving: 'Applying…',
185
+ serversJsonDone: 'Applied',
186
+ globalEnv: 'Process env vars',
187
+ globalEnvHint: 'Process-level environment variables referenced by every server\'s header substitution (write ${NAME} or a bare name in a header value). Secret values are stored in the credentials document, never shown here; blank keeps the stored value.',
188
+ globalEnvName: 'Name',
189
+ globalEnvValue: 'Value',
190
+ globalEnvSecret: 'secret',
191
+ globalEnvSecretHint: 'Write-only; a blank value keeps the stored one',
192
+ globalEnvAdd: 'Add variable',
193
+ globalEnvBulk: 'Add many',
194
+ globalEnvBulkPrompt: 'One variable per line, as NAME=value; a bare NAME adds a name-only row.',
195
+ globalEnvBulkOk: 'Add',
196
+ globalEnvSave: 'Save',
197
+ globalEnvSaving: 'Saving…',
198
+ globalEnvRemove: 'Remove this variable',
199
+ globalEnvEmpty: '(no process env vars)',
200
+ globalEnvLoadFailed: 'Failed to read environment variables',
201
+ globalEnvRetry: 'Retry',
202
+ globalEnvDone: 'Saved',
203
+ configTitle: 'MCP config',
154
204
  } satisfies Record<McpSettingsLocaleKey, string>