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.
- package/CHANGELOG.en.md +17 -0
- package/CHANGELOG.md +17 -0
- package/README.en.md +23 -5
- package/README.md +19 -4
- package/lib/client.js +970 -364
- package/lib/index.js +321 -12
- 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 +1 -55
- 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 -43
- package/src/client/remote-contribution.js +163 -2
- package/src/client/types.ts +2 -0
|
@@ -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
|
-
|
|
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
|
-
{
|
|
295
|
-
<
|
|
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.
|
|
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
|
-
|
|
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
|
}
|