dsh-mcp 1.0.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/LICENSE +21 -0
- package/README.en.md +106 -0
- package/README.md +89 -0
- package/lib/client.js +15820 -0
- package/lib/index.js +894 -0
- package/lib/probe.js +53 -0
- package/lib/transport.js +25 -0
- package/package.json +47 -0
- package/scripts/build.mjs +144 -0
- package/src/client/McpSettingsSection.module.css +236 -0
- package/src/client/McpSettingsSection.tsx +351 -0
- package/src/client/ServerForm.module.css +196 -0
- package/src/client/ServerForm.tsx +301 -0
- package/src/client/ToolControlSection.module.css +100 -0
- package/src/client/ToolControlSection.tsx +137 -0
- package/src/client/index.ts +160 -0
- package/src/client/locales.ts +152 -0
- package/src/client/mcp-store.ts +249 -0
- package/src/client/remote-contribution.js +339 -0
- package/src/client/types.ts +124 -0
- package/src/css-modules.d.ts +6 -0
- package/static/snapshot.webp +0 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import type { McpServerId, McpServerView } from './types.ts'
|
|
3
|
+
import type {
|
|
4
|
+
McpDraft, McpEnvRowDraft, McpTestOutcome,
|
|
5
|
+
} from './mcp-store.ts'
|
|
6
|
+
import { createEnvRowDraft } from './mcp-store.ts'
|
|
7
|
+
import type { McpSettingsLocaleKey } from './locales.ts'
|
|
8
|
+
import css from './ServerForm.module.css'
|
|
9
|
+
|
|
10
|
+
/** The Remote verbs the editor needs; structurally the section's inject face. */
|
|
11
|
+
export interface McpServerFormRemote {
|
|
12
|
+
save: (draft: McpDraft) => Promise<{ message: string } | null>
|
|
13
|
+
remove: (id: McpServerId) => Promise<{ message: string } | null>
|
|
14
|
+
test: (draft: McpDraft) => Promise<McpTestOutcome>
|
|
15
|
+
list: () => Promise<readonly McpServerView[]>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Store actions the editor drives. */
|
|
19
|
+
export interface McpServerFormActions {
|
|
20
|
+
updateDraft: (patch: Partial<McpDraft>) => void
|
|
21
|
+
cancelEdit: () => void
|
|
22
|
+
setBusy: (busy: 'save' | 'remove' | null) => void
|
|
23
|
+
setTestRunning: (running: boolean) => void
|
|
24
|
+
setTest: (test: McpTestOutcome | null) => void
|
|
25
|
+
setServers: (servers: readonly McpServerView[]) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Full editor props assembled by the section. */
|
|
29
|
+
export interface McpServerFormProps {
|
|
30
|
+
draft: McpDraft
|
|
31
|
+
busy: 'save' | 'remove' | null
|
|
32
|
+
testRunning: boolean
|
|
33
|
+
test: McpTestOutcome | null
|
|
34
|
+
t: (key: McpSettingsLocaleKey) => string
|
|
35
|
+
actions: McpServerFormActions
|
|
36
|
+
injected: McpServerFormRemote
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render the editor for one server draft. */
|
|
40
|
+
export function McpServerForm(props: McpServerFormProps): ReactNode {
|
|
41
|
+
const { draft, busy, testRunning, test, t, actions, injected } = props
|
|
42
|
+
const [saveError, setSaveError] = useState<string | null>(null)
|
|
43
|
+
|
|
44
|
+
const update = (patch: Partial<McpDraft>): void => {
|
|
45
|
+
setSaveError(null)
|
|
46
|
+
actions.updateDraft(patch)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const updateEnvRow = (key: string, patch: Partial<McpEnvRowDraft>): void => {
|
|
50
|
+
update({ env: draft.env.map(row => row.key === key ? { ...row, ...patch } : row) })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const removeEnvRow = (key: string): void => {
|
|
54
|
+
update({ env: draft.env.filter(row => row.key !== key) })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const addEnvRow = (): void => {
|
|
58
|
+
update({ env: [...draft.env, createEnvRowDraft()] })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const runTest = async (): Promise<void> => {
|
|
62
|
+
setSaveError(null)
|
|
63
|
+
actions.setTestRunning(true)
|
|
64
|
+
try {
|
|
65
|
+
actions.setTest(await injected.test(draft))
|
|
66
|
+
} catch {
|
|
67
|
+
actions.setTest(null)
|
|
68
|
+
} finally {
|
|
69
|
+
actions.setTestRunning(false)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const submit = async (): Promise<void> => {
|
|
74
|
+
setSaveError(null)
|
|
75
|
+
actions.setBusy('save')
|
|
76
|
+
try {
|
|
77
|
+
const failure = await injected.save(draft)
|
|
78
|
+
if (failure !== null) {
|
|
79
|
+
setSaveError(failure.message)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
await refresh()
|
|
83
|
+
actions.cancelEdit()
|
|
84
|
+
} catch {
|
|
85
|
+
setSaveError(t('failureTitle'))
|
|
86
|
+
} finally {
|
|
87
|
+
actions.setBusy(null)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const removeServer = async (): Promise<void> => {
|
|
92
|
+
if (draft.id === null || !window.confirm(t('removeConfirm'))) return
|
|
93
|
+
setSaveError(null)
|
|
94
|
+
actions.setBusy('remove')
|
|
95
|
+
try {
|
|
96
|
+
const failure = await injected.remove(draft.id)
|
|
97
|
+
if (failure !== null) {
|
|
98
|
+
setSaveError(failure.message)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
await refresh()
|
|
102
|
+
actions.cancelEdit()
|
|
103
|
+
} catch {
|
|
104
|
+
setSaveError(t('failureTitle'))
|
|
105
|
+
} finally {
|
|
106
|
+
actions.setBusy(null)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const refresh = async (): Promise<void> => {
|
|
111
|
+
try {
|
|
112
|
+
actions.setServers(await injected.list())
|
|
113
|
+
} catch {
|
|
114
|
+
// The save/remove outcome is the actionable one; a failed refresh is
|
|
115
|
+
// re-run on the next page visit.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const stdio = draft.transport === 'stdio'
|
|
120
|
+
const probe = test?.probe
|
|
121
|
+
return (
|
|
122
|
+
<form className={css.form} onSubmit={(event) => { event.preventDefault(); void submit() }}>
|
|
123
|
+
<div className={css.titleRow}>
|
|
124
|
+
<h3 className={css.title}>{draft.id === null ? t('newTitle') : t('editTitle')}</h3>
|
|
125
|
+
<div className={css.titleActions}>
|
|
126
|
+
<button type="button" onClick={() => actions.cancelEdit()}>{t('backToList')}</button>
|
|
127
|
+
{draft.id !== null ? (
|
|
128
|
+
<button type="button" className={css.danger} disabled={busy !== null} onClick={() => void removeServer()}>
|
|
129
|
+
{busy === 'remove' ? t('removing') : t('remove')}
|
|
130
|
+
</button>
|
|
131
|
+
) : null}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{saveError !== null ? <p className={css.error} role="alert">{saveError}</p> : null}
|
|
136
|
+
|
|
137
|
+
<label className={css.field}>
|
|
138
|
+
<span>{t('serverName')}</span>
|
|
139
|
+
<input
|
|
140
|
+
type="text"
|
|
141
|
+
value={draft.serverName}
|
|
142
|
+
placeholder={t('serverNameHint')}
|
|
143
|
+
onChange={(event) => update({ serverName: event.currentTarget.value })}
|
|
144
|
+
/>
|
|
145
|
+
</label>
|
|
146
|
+
|
|
147
|
+
<label className={css.field}>
|
|
148
|
+
<span>{t('transport')}</span>
|
|
149
|
+
<select
|
|
150
|
+
value={draft.transport}
|
|
151
|
+
onChange={(event) => update({ transport: event.currentTarget.value as McpDraft['transport'] })}
|
|
152
|
+
>
|
|
153
|
+
<option value="stdio">{t('transportStdio')}</option>
|
|
154
|
+
<option value="streamable-http">{t('transportHttp')}</option>
|
|
155
|
+
</select>
|
|
156
|
+
</label>
|
|
157
|
+
|
|
158
|
+
{stdio ? (
|
|
159
|
+
<>
|
|
160
|
+
<label className={css.field}>
|
|
161
|
+
<span>{t('command')}</span>
|
|
162
|
+
<input
|
|
163
|
+
type="text"
|
|
164
|
+
value={draft.command}
|
|
165
|
+
placeholder={t('commandPlaceholder')}
|
|
166
|
+
onChange={(event) => update({ command: event.currentTarget.value })}
|
|
167
|
+
/>
|
|
168
|
+
</label>
|
|
169
|
+
<label className={css.field}>
|
|
170
|
+
<span>{t('args')}</span>
|
|
171
|
+
<textarea
|
|
172
|
+
rows={2}
|
|
173
|
+
value={draft.argsText}
|
|
174
|
+
placeholder={t('argsPlaceholder')}
|
|
175
|
+
onChange={(event) => update({ argsText: event.currentTarget.value })}
|
|
176
|
+
/>
|
|
177
|
+
</label>
|
|
178
|
+
<label className={css.field}>
|
|
179
|
+
<span>{t('cwd')}</span>
|
|
180
|
+
<input
|
|
181
|
+
type="text"
|
|
182
|
+
value={draft.cwd}
|
|
183
|
+
placeholder={t('cwdPlaceholder')}
|
|
184
|
+
onChange={(event) => update({ cwd: event.currentTarget.value })}
|
|
185
|
+
/>
|
|
186
|
+
</label>
|
|
187
|
+
</>
|
|
188
|
+
) : (
|
|
189
|
+
<>
|
|
190
|
+
<label className={css.field}>
|
|
191
|
+
<span>{t('url')}</span>
|
|
192
|
+
<input
|
|
193
|
+
type="url"
|
|
194
|
+
value={draft.url}
|
|
195
|
+
placeholder={t('urlPlaceholder')}
|
|
196
|
+
onChange={(event) => update({ url: event.currentTarget.value })}
|
|
197
|
+
/>
|
|
198
|
+
</label>
|
|
199
|
+
<label className={css.field}>
|
|
200
|
+
<span>{t('headers')}</span>
|
|
201
|
+
<textarea
|
|
202
|
+
rows={2}
|
|
203
|
+
value={draft.headersText}
|
|
204
|
+
placeholder={t('headersPlaceholder')}
|
|
205
|
+
onChange={(event) => update({ headersText: event.currentTarget.value })}
|
|
206
|
+
/>
|
|
207
|
+
</label>
|
|
208
|
+
</>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{stdio ? (
|
|
212
|
+
<fieldset className={css.envBlock}>
|
|
213
|
+
<legend>{t('envVars')}</legend>
|
|
214
|
+
<p className={css.hint}>{t('envHint')}</p>
|
|
215
|
+
{draft.env.length === 0 ? <p className={css.muted}>{t('envVars')}: 0</p> : null}
|
|
216
|
+
{draft.env.map(row => (
|
|
217
|
+
<div key={row.key} className={css.envRow}>
|
|
218
|
+
<input
|
|
219
|
+
type="text"
|
|
220
|
+
className={css.envName}
|
|
221
|
+
value={row.name}
|
|
222
|
+
placeholder={t('envName')}
|
|
223
|
+
aria-label={t('envName')}
|
|
224
|
+
onChange={(event) => updateEnvRow(row.key, { name: event.currentTarget.value })}
|
|
225
|
+
/>
|
|
226
|
+
<input
|
|
227
|
+
type={row.secret ? 'password' : 'text'}
|
|
228
|
+
className={css.envValue}
|
|
229
|
+
value={row.value}
|
|
230
|
+
placeholder={row.secret && row.configured ? '••••••••' : t('envValue')}
|
|
231
|
+
aria-label={t('envValue')}
|
|
232
|
+
onChange={(event) => updateEnvRow(row.key, { value: event.currentTarget.value })}
|
|
233
|
+
/>
|
|
234
|
+
<label className={css.secretToggle}>
|
|
235
|
+
<input
|
|
236
|
+
type="checkbox"
|
|
237
|
+
checked={row.secret}
|
|
238
|
+
title={t('envSecretHint')}
|
|
239
|
+
onChange={(event) => updateEnvRow(row.key, { secret: event.currentTarget.checked })}
|
|
240
|
+
/>
|
|
241
|
+
{t('envSecret')}
|
|
242
|
+
</label>
|
|
243
|
+
<button type="button" className={css.danger} aria-label={t('removeEnvVar')} onClick={() => removeEnvRow(row.key)}>
|
|
244
|
+
✕
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
))}
|
|
248
|
+
<button type="button" onClick={addEnvRow}>{t('addEnvVar')}</button>
|
|
249
|
+
</fieldset>
|
|
250
|
+
) : null}
|
|
251
|
+
|
|
252
|
+
<label className={css.field}>
|
|
253
|
+
<span>{t('toolCallTimeoutMs')}</span>
|
|
254
|
+
<input
|
|
255
|
+
type="number"
|
|
256
|
+
min={1}
|
|
257
|
+
value={draft.toolCallTimeoutMs}
|
|
258
|
+
onChange={(event) => update({ toolCallTimeoutMs: event.currentTarget.value })}
|
|
259
|
+
/>
|
|
260
|
+
</label>
|
|
261
|
+
|
|
262
|
+
<label className={css.checkRow}>
|
|
263
|
+
<input
|
|
264
|
+
type="checkbox"
|
|
265
|
+
checked={draft.failOnStartupError}
|
|
266
|
+
onChange={(event) => update({ failOnStartupError: event.currentTarget.checked })}
|
|
267
|
+
/>
|
|
268
|
+
{t('failOnStartupError')}
|
|
269
|
+
</label>
|
|
270
|
+
|
|
271
|
+
<label className={css.checkRow}>
|
|
272
|
+
<input
|
|
273
|
+
type="checkbox"
|
|
274
|
+
checked={draft.enabled}
|
|
275
|
+
onChange={(event) => update({ enabled: event.currentTarget.checked })}
|
|
276
|
+
/>
|
|
277
|
+
{t('enabled')}
|
|
278
|
+
</label>
|
|
279
|
+
|
|
280
|
+
{test !== null ? (
|
|
281
|
+
<p className={`${css.testResult} ${probe !== undefined && probe.ok ? css.testOk : css.testFail}`} role="status">
|
|
282
|
+
{probe !== undefined && probe.ok
|
|
283
|
+
? `${t('testOk')} (${probe.tools.length} ${t('toolCount')}, ${test.elapsedMs}ms)`
|
|
284
|
+
: probe !== undefined && !probe.ok
|
|
285
|
+
? `${t('testFail')}: ${probe.message}`
|
|
286
|
+
: t('testUnknown')}
|
|
287
|
+
</p>
|
|
288
|
+
) : null}
|
|
289
|
+
|
|
290
|
+
<div className={css.actions}>
|
|
291
|
+
<button type="button" disabled={testRunning || busy !== null} onClick={() => void runTest()}>
|
|
292
|
+
{testRunning ? t('testRunning') : t('test')}
|
|
293
|
+
</button>
|
|
294
|
+
<button type="submit" className={css.primary} disabled={busy !== null || testRunning}>
|
|
295
|
+
{busy === 'save' ? t('saving') : t('save')}
|
|
296
|
+
</button>
|
|
297
|
+
<button type="button" disabled={busy !== null} onClick={() => actions.cancelEdit()}>{t('cancel')}</button>
|
|
298
|
+
</div>
|
|
299
|
+
</form>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
.section {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: 10px;
|
|
5
|
+
width: 100%;
|
|
6
|
+
border-top: 1px solid var(--dsw-alias-border-l2);
|
|
7
|
+
padding-top: 16px;
|
|
8
|
+
margin-top: 4px;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.title {
|
|
12
|
+
margin: 0;
|
|
13
|
+
font-size: 14px;
|
|
14
|
+
line-height: 20px;
|
|
15
|
+
font-weight: 600;
|
|
16
|
+
color: var(--dsw-alias-label-primary);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.mode {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 14px;
|
|
23
|
+
font-size: 13px;
|
|
24
|
+
line-height: 20px;
|
|
25
|
+
color: var(--dsw-alias-label-primary);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.modeLabel {
|
|
29
|
+
font-weight: 500;
|
|
30
|
+
color: var(--dsw-alias-label-secondary);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.mode label {
|
|
34
|
+
display: inline-flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: 4px;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
user-select: none;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.mode input {
|
|
42
|
+
margin: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.hint {
|
|
46
|
+
margin: 0;
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
line-height: 18px;
|
|
49
|
+
color: var(--dsw-alias-label-tertiary);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.list {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-direction: column;
|
|
55
|
+
max-height: 320px;
|
|
56
|
+
overflow-y: auto;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.groupTitle {
|
|
60
|
+
margin: 10px 0 4px;
|
|
61
|
+
font-size: 13px;
|
|
62
|
+
line-height: 18px;
|
|
63
|
+
font-weight: 600;
|
|
64
|
+
color: var(--dsw-alias-label-secondary);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.row {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 8px;
|
|
71
|
+
padding: 5px 2px;
|
|
72
|
+
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
font-size: 13px;
|
|
75
|
+
line-height: 18px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.row input {
|
|
79
|
+
flex: none;
|
|
80
|
+
margin: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.name {
|
|
84
|
+
flex: none;
|
|
85
|
+
max-width: 44%;
|
|
86
|
+
overflow: hidden;
|
|
87
|
+
text-overflow: ellipsis;
|
|
88
|
+
white-space: nowrap;
|
|
89
|
+
font-weight: 500;
|
|
90
|
+
color: var(--dsw-alias-label-primary);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.desc {
|
|
94
|
+
flex: 1;
|
|
95
|
+
min-width: 0;
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
text-overflow: ellipsis;
|
|
98
|
+
white-space: nowrap;
|
|
99
|
+
color: var(--dsw-alias-label-tertiary);
|
|
100
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-control section merged into the MCP settings page: injection-mode
|
|
3
|
+
* selector (full / on-demand search) plus per-tool enable switches grouped by
|
|
4
|
+
* MCP server. Reads and writes the Host's tool-control Remote methods; all
|
|
5
|
+
* state is process-local on the Host, so the page just reflects it.
|
|
6
|
+
* @module dsh-mcp/client/ToolControlSection
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
10
|
+
import type {
|
|
11
|
+
McpToolInjectionMode, McpToolView, McpToolsState,
|
|
12
|
+
} from './types.ts'
|
|
13
|
+
import type { McpSettingsLocaleKey } from './locales.ts'
|
|
14
|
+
import css from './ToolControlSection.module.css'
|
|
15
|
+
|
|
16
|
+
/** Host Remote face required by this section. */
|
|
17
|
+
export interface McpToolControlRemote {
|
|
18
|
+
/** Read the tool-control state. */
|
|
19
|
+
toolsList: () => Promise<McpToolsState>
|
|
20
|
+
/** Set one tool's enable switch. */
|
|
21
|
+
toolsSet: (request: { name: string; enabled: boolean }) => Promise<{ ok: boolean }>
|
|
22
|
+
/** Switch the injection mode. */
|
|
23
|
+
toolsMode: (request: { mode: McpToolInjectionMode }) => Promise<{ ok: boolean }>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Props: the injected Remote face plus the bound locale `t`. */
|
|
27
|
+
export interface ToolControlSectionProps {
|
|
28
|
+
readonly injected: McpToolControlRemote
|
|
29
|
+
readonly t: (key: McpSettingsLocaleKey) => string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Display name of one tool: the raw `mcp__<server>__<tool>` tail. */
|
|
33
|
+
function rawOf(name: string): string {
|
|
34
|
+
const rest = name.slice(5)
|
|
35
|
+
const i = rest.indexOf('__')
|
|
36
|
+
return i < 0 ? rest : rest.slice(i + 2)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render the mode selector and grouped per-tool switches. */
|
|
40
|
+
export function ToolControlSection({ injected, t }: ToolControlSectionProps): ReactNode {
|
|
41
|
+
const [state, setState] = useState<McpToolsState | null>(null)
|
|
42
|
+
|
|
43
|
+
const refresh = (): void => {
|
|
44
|
+
void injected.toolsList().then(setState, () => setState(null))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
let current = true
|
|
49
|
+
void injected.toolsList().then(
|
|
50
|
+
(next) => { if (current) setState(next) },
|
|
51
|
+
() => { if (current) setState(null) },
|
|
52
|
+
)
|
|
53
|
+
return () => { current = false }
|
|
54
|
+
}, [injected])
|
|
55
|
+
|
|
56
|
+
const setMode = (mode: McpToolInjectionMode): void => {
|
|
57
|
+
void injected.toolsMode({ mode }).then(refresh, (error) => {
|
|
58
|
+
console.error('[dsh-mcp] toolsMode failed:', error)
|
|
59
|
+
refresh()
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
const toggle = (tool: McpToolView): void => {
|
|
63
|
+
void injected.toolsSet({ name: tool.name, enabled: !tool.enabled }).then(refresh, (error) => {
|
|
64
|
+
console.error('[dsh-mcp] toolsSet failed:', error)
|
|
65
|
+
refresh()
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const groups = new Map<string, McpToolView[]>()
|
|
70
|
+
for (const tool of state?.tools ?? []) {
|
|
71
|
+
const bucket = groups.get(tool.server) ?? []
|
|
72
|
+
bucket.push(tool)
|
|
73
|
+
groups.set(tool.server, bucket)
|
|
74
|
+
}
|
|
75
|
+
const servers = [...groups.keys()].sort()
|
|
76
|
+
|
|
77
|
+
const rows: ReactNode[] = []
|
|
78
|
+
for (const server of servers) {
|
|
79
|
+
rows.push(
|
|
80
|
+
<div key={server} className={css.groupTitle}>
|
|
81
|
+
{server}({groups.get(server)!.length} 个)
|
|
82
|
+
</div>,
|
|
83
|
+
)
|
|
84
|
+
for (const tool of groups.get(server)!) {
|
|
85
|
+
rows.push(
|
|
86
|
+
<label key={tool.name} className={css.row} title={tool.name}>
|
|
87
|
+
<input
|
|
88
|
+
type="checkbox"
|
|
89
|
+
checked={tool.enabled}
|
|
90
|
+
onChange={() => toggle(tool)}
|
|
91
|
+
/>
|
|
92
|
+
<span className={css.name}>{rawOf(tool.name)}</span>
|
|
93
|
+
<span className={css.desc}>{tool.description}</span>
|
|
94
|
+
</label>,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<section className={css.section}>
|
|
101
|
+
<h3 className={css.title}>{t('toolsTitle')}</h3>
|
|
102
|
+
<div className={css.mode}>
|
|
103
|
+
<span className={css.modeLabel}>{t('toolsModeLabel')}</span>
|
|
104
|
+
<label>
|
|
105
|
+
<input
|
|
106
|
+
type="radio"
|
|
107
|
+
name="mcp-tool-mode"
|
|
108
|
+
checked={state?.mode === 'full'}
|
|
109
|
+
onChange={() => setMode('full')}
|
|
110
|
+
/>
|
|
111
|
+
{t('toolsModeFull')}
|
|
112
|
+
</label>
|
|
113
|
+
<label>
|
|
114
|
+
<input
|
|
115
|
+
type="radio"
|
|
116
|
+
name="mcp-tool-mode"
|
|
117
|
+
checked={state?.mode === 'search'}
|
|
118
|
+
onChange={() => setMode('search')}
|
|
119
|
+
/>
|
|
120
|
+
{t('toolsModeSearch')}
|
|
121
|
+
</label>
|
|
122
|
+
</div>
|
|
123
|
+
<p className={css.hint}>
|
|
124
|
+
{state === null
|
|
125
|
+
? t('toolsLoading')
|
|
126
|
+
: state.mode === 'search'
|
|
127
|
+
? t('toolsHintSearch')
|
|
128
|
+
: t('toolsHintFull')}
|
|
129
|
+
</p>
|
|
130
|
+
{state === null ? null : rows.length === 0 ? (
|
|
131
|
+
<p className={css.hint}>{t('toolsEmpty')}</p>
|
|
132
|
+
) : (
|
|
133
|
+
<div className={css.list}>{rows}</div>
|
|
134
|
+
)}
|
|
135
|
+
</section>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server management settings page, browser half: one `settings.section`
|
|
3
|
+
* entry named `mcp` over the `mcpManager` Remote namespace.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
8
|
+
// Type-only: the settings shell's SlotMap merge (the 'settings.section' entry).
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
10
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
11
|
+
// Local wire vocabulary (vendored from the original mcp-manager types).
|
|
12
|
+
import type { McpManagerFailure, McpServerId, McpServerView } from './types.ts'
|
|
13
|
+
import remoteContribution from './remote-contribution.js'
|
|
14
|
+
import { McpSettingsSection, type McpManagerInjected } from './McpSettingsSection.tsx'
|
|
15
|
+
import { en, zh, type McpSettingsLocaleKey } from './locales.ts'
|
|
16
|
+
import { createMcpManagerStore, draftToSubmission } from './mcp-store.ts'
|
|
17
|
+
|
|
18
|
+
/** One Remote carrier result: success carries the decoded value, failure a code. */
|
|
19
|
+
type McpRemoteResult<Value> =
|
|
20
|
+
| { readonly ok: true; readonly value: Value }
|
|
21
|
+
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
|
|
22
|
+
|
|
23
|
+
/** Wire face of the self-mounted `mcpManager` Remote namespace. */
|
|
24
|
+
interface McpManagerRemote {
|
|
25
|
+
list(): Promise<McpRemoteResult<{ readonly servers: readonly McpServerView[] }>>
|
|
26
|
+
upsert(request: {
|
|
27
|
+
readonly id?: McpServerId
|
|
28
|
+
readonly server: unknown
|
|
29
|
+
readonly env: readonly unknown[]
|
|
30
|
+
}): Promise<McpRemoteResult<{ readonly server: McpServerView }>>
|
|
31
|
+
delete(request: { readonly id: McpServerId }): Promise<McpRemoteResult<unknown>>
|
|
32
|
+
test(request: {
|
|
33
|
+
readonly id?: McpServerId
|
|
34
|
+
readonly server: unknown
|
|
35
|
+
readonly env: readonly unknown[]
|
|
36
|
+
}): Promise<McpRemoteResult<{ readonly probe: unknown; readonly elapsedMs: number }>>
|
|
37
|
+
toolsList(): Promise<McpRemoteResult<{ readonly tools: readonly unknown[]; readonly mode: unknown; readonly hotSize: number }>>
|
|
38
|
+
toolsSet(request: { readonly name: string; readonly enabled: boolean }): Promise<McpRemoteResult<{ readonly ok: boolean }>>
|
|
39
|
+
toolsMode(request: { readonly mode: unknown }): Promise<McpRemoteResult<{ readonly ok: boolean }>>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type { McpSettingsSectionProps, McpManagerInjected } from './McpSettingsSection.tsx'
|
|
43
|
+
export type { McpServerFormProps } from './ServerForm.tsx'
|
|
44
|
+
export type { McpDraft, McpEnvRowDraft, McpTestOutcome } from './mcp-store.ts'
|
|
45
|
+
export type { McpSettingsLocaleKey } from './locales.ts'
|
|
46
|
+
|
|
47
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
48
|
+
interface LocaleNamespaceMap {
|
|
49
|
+
/** MCP server management copy. */
|
|
50
|
+
'settings.mcp': McpSettingsLocaleKey
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Dictionary namespace owned by this plugin. */
|
|
55
|
+
export const NS = 'settings.mcp'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Services required by the Settings registration and the Remote mount. The
|
|
59
|
+
* `mcpManager` Remote namespace is NOT injected: this standalone plugin mounts
|
|
60
|
+
* the contribution itself in `apply`, so waiting for `remote.mcpManager` here
|
|
61
|
+
* would deadlock against its own mount.
|
|
62
|
+
*/
|
|
63
|
+
export const inject = ['slots', 'locale', 'remote']
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve one Remote call: unwrap the value or throw on a carrier failure.
|
|
67
|
+
* @param run - The typed Remote method invocation.
|
|
68
|
+
* @returns the business value.
|
|
69
|
+
*/
|
|
70
|
+
async function unwrap<Value>(
|
|
71
|
+
run: () => Promise<{ ok: true; value: Value } | { ok: false; error: { code: string; message: string } }>,
|
|
72
|
+
): Promise<Value> {
|
|
73
|
+
const result = await run()
|
|
74
|
+
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
|
75
|
+
return result.value
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Map a Remote failure to the manager's business failure vocabulary. */
|
|
79
|
+
function failureOf(error: { code: string; message: string }): McpManagerFailure {
|
|
80
|
+
return { code: error.code as McpManagerFailure['code'], message: error.message }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Contribute the MCP management page to the Settings section. Unlike the
|
|
85
|
+
* original assembly (where the api-remotes facade mounted every Remote
|
|
86
|
+
* namespace), this standalone plugin mounts the `mcpManager` contribution
|
|
87
|
+
* itself so it needs no modification to any in-box package.
|
|
88
|
+
* @param ctx - Client Cordis root.
|
|
89
|
+
*/
|
|
90
|
+
export async function apply(ctx: ClientContext): Promise<void> {
|
|
91
|
+
await ctx.remote.$mount(remoteContribution)
|
|
92
|
+
|
|
93
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-mcp: dictionaries')
|
|
94
|
+
|
|
95
|
+
const t = ctx.locale.bind(NS)
|
|
96
|
+
|
|
97
|
+
// The mcpManager namespace is self-mounted by $mount above, so injecting
|
|
98
|
+
// `remote.mcpManager` would deadlock this plugin's own activation. Read the
|
|
99
|
+
// provided service through the global service store instead of the ctx
|
|
100
|
+
// property proxy, which would demand an inject declaration.
|
|
101
|
+
const manager = ctx.get('remote.mcpManager') as McpManagerRemote | undefined
|
|
102
|
+
if (manager === undefined) {
|
|
103
|
+
throw new Error('dsh-mcp: remote.mcpManager namespace is not mounted after $mount')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const injected = (): McpManagerInjected => ({
|
|
107
|
+
list: async () => {
|
|
108
|
+
const result = await unwrap(() => manager.list())
|
|
109
|
+
return result.servers
|
|
110
|
+
},
|
|
111
|
+
save: async (draft) => {
|
|
112
|
+
const submission = draftToSubmission(draft)
|
|
113
|
+
const result = await manager.upsert({
|
|
114
|
+
...draft.id === null ? {} : { id: draft.id },
|
|
115
|
+
server: submission.server,
|
|
116
|
+
env: submission.env,
|
|
117
|
+
})
|
|
118
|
+
if (result.ok) return null
|
|
119
|
+
return failureOf(result.error)
|
|
120
|
+
},
|
|
121
|
+
remove: async (id) => {
|
|
122
|
+
const result = await manager.delete({ id })
|
|
123
|
+
if (result.ok) return null
|
|
124
|
+
return failureOf(result.error)
|
|
125
|
+
},
|
|
126
|
+
test: async (draft) => {
|
|
127
|
+
const submission = draftToSubmission(draft)
|
|
128
|
+
const result = await unwrap(() => manager.test({
|
|
129
|
+
...draft.id === null ? {} : { id: draft.id },
|
|
130
|
+
server: submission.server,
|
|
131
|
+
env: submission.env,
|
|
132
|
+
}))
|
|
133
|
+
return { probe: result.probe, elapsedMs: result.elapsedMs }
|
|
134
|
+
},
|
|
135
|
+
toolsList: async () => {
|
|
136
|
+
// The contribution declares no parameters for toolsList; pass none.
|
|
137
|
+
const result = await unwrap(() => manager.toolsList())
|
|
138
|
+
return { tools: result.tools, mode: result.mode, hotSize: result.hotSize }
|
|
139
|
+
},
|
|
140
|
+
toolsSet: async (request) => {
|
|
141
|
+
// `request` is the single wire parameter: pass the payload directly.
|
|
142
|
+
const result = await unwrap(() => manager.toolsSet(request))
|
|
143
|
+
return { ok: result.ok }
|
|
144
|
+
},
|
|
145
|
+
toolsMode: async (request) => {
|
|
146
|
+
const result = await unwrap(() => manager.toolsMode({ mode: request.mode }))
|
|
147
|
+
return { ok: result.ok }
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
152
|
+
name: 'settings.section',
|
|
153
|
+
id: 'mcp',
|
|
154
|
+
order: 25,
|
|
155
|
+
label: () => t('nav'),
|
|
156
|
+
locale: NS,
|
|
157
|
+
store: createMcpManagerStore,
|
|
158
|
+
inject: injected,
|
|
159
|
+
}, McpSettingsSection))
|
|
160
|
+
}
|