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.
- package/CHANGELOG.en.md +29 -0
- package/CHANGELOG.md +29 -0
- package/README.en.md +23 -5
- package/README.md +19 -4
- package/lib/client.js +989 -388
- package/lib/index.js +322 -13
- package/lib/oauth.js +46 -5
- package/package.json +1 -1
- package/src/client/GlobalEnvEditor.module.css +194 -0
- package/src/client/GlobalEnvEditor.tsx +245 -0
- package/src/client/McpSettingsSection.module.css +67 -0
- package/src/client/McpSettingsSection.tsx +82 -18
- package/src/client/ServerForm.module.css +0 -47
- package/src/client/ServerForm.tsx +25 -70
- package/src/client/ServersJsonEditor.module.css +90 -0
- package/src/client/ServersJsonEditor.tsx +226 -0
- package/src/client/index.ts +21 -3
- package/src/client/locales.ts +54 -4
- package/src/client/mcp-store.ts +4 -54
- package/src/client/remote-contribution.js +163 -2
- package/src/client/types.ts +2 -0
|
@@ -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,
|
|
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<
|
|
13
|
-
remove: (id: McpServerId) => Promise<
|
|
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
|
|
52
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
107
|
+
const refresh = async (): Promise<boolean> => {
|
|
114
108
|
try {
|
|
115
109
|
actions.setServers(await injected.list())
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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,
|
|
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({
|
package/src/client/locales.ts
CHANGED
|
@@ -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: '
|
|
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: '
|
|
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>
|