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.
@@ -0,0 +1,351 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
2
+ import type {
3
+ McpManagerFailure, McpServerId, McpServerView,
4
+ McpToolInjectionMode, McpToolsState, McpToolView,
5
+ } from './types.ts'
6
+ import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
7
+ import { McpServerForm, type McpServerFormRemote } from './ServerForm.tsx'
8
+ import type { McpToolControlRemote } from './ToolControlSection.tsx'
9
+ import type { McpSettingsLocaleKey } from './locales.ts'
10
+ import { createMcpManagerStore, type McpDraft, type McpTestOutcome } from './mcp-store.ts'
11
+ import css from './McpSettingsSection.module.css'
12
+
13
+ /** Registration-side Remote face used by the section. */
14
+ export interface McpManagerInjected extends McpServerFormRemote, McpToolControlRemote {
15
+ /** Read the current server list. */
16
+ list: () => Promise<readonly McpServerView[]>
17
+ /** Persist one draft; null means success, a failure is otherwise returned. */
18
+ save: (draft: McpDraft) => Promise<McpManagerFailure | null>
19
+ /** Delete one server; null means success, a failure is otherwise returned. */
20
+ remove: (id: McpServerId) => Promise<McpManagerFailure | null>
21
+ /** Probe one draft; `draft.id` lets stored secret values resolve. */
22
+ test: (draft: McpDraft) => Promise<McpTestOutcome>
23
+ }
24
+
25
+ /** Full component props assembled by the Settings slot renderer. */
26
+ export type McpSettingsSectionProps =
27
+ PropsRuntime<'settings.section'>
28
+ & PropsLocale<'settings.mcp'>
29
+ & PropsStore<ReturnType<typeof createMcpManagerStore>>
30
+ & InjectFace<McpManagerInjected>
31
+
32
+ /** Phase label key for a status badge. */
33
+ function phaseKey(phase: McpServerView['status']['phase']): McpSettingsLocaleKey {
34
+ switch (phase) {
35
+ case 'mounting': return 'statusMounting'
36
+ case 'live': return 'statusLive'
37
+ case 'failed': return 'statusFailed'
38
+ case 'stopped': return 'statusStopped'
39
+ }
40
+ }
41
+
42
+ /** Raw `mcp__<server>__<tool>` tail, for the tool list. */
43
+ function rawOf(name: string): string {
44
+ const rest = name.slice(5)
45
+ const i = rest.indexOf('__')
46
+ return i < 0 ? rest : rest.slice(i + 2)
47
+ }
48
+
49
+ /** Rebuild an editable draft from a stored server view (quick toggle path). */
50
+ function viewToDraft(server: McpServerView): McpDraft {
51
+ return {
52
+ id: server.id,
53
+ serverName: server.serverName,
54
+ transport: server.transport,
55
+ enabled: server.enabled,
56
+ command: server.command,
57
+ argsText: server.args.join('\n'),
58
+ cwd: server.cwd,
59
+ url: server.url,
60
+ 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
+ toolCallTimeoutMs: String(server.toolCallTimeoutMs),
69
+ failOnStartupError: server.failOnStartupError,
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Render the MCP management page: the injection-mode selector (default
75
+ * on-demand search), the server list with per-server refresh and an expandable
76
+ * per-server tool-binding list, or the editor when a draft is open.
77
+ */
78
+ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
79
+ const { list, save, remove, test } = props
80
+ const state = props.useStore(snapshot => snapshot)
81
+ const { setLoadState, setServers, beginCreate, beginEdit, cancelEdit, updateDraft, setBusy, setTestRunning, setTest } = props.actions
82
+ const t = props.t
83
+
84
+ const [loadErrorDetail, setLoadErrorDetail] = useState<string | null>(null)
85
+ const [tools, setTools] = useState<McpToolsState | null>(null)
86
+ const [toolsError, setToolsError] = useState<string | null>(null)
87
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set())
88
+ const [refreshing, setRefreshing] = useState<ReadonlySet<string>>(new Set())
89
+ const timersRef = useRef<number[]>([])
90
+
91
+ useEffect(() => () => {
92
+ for (const id of timersRef.current) window.clearTimeout(id)
93
+ }, [])
94
+
95
+ const refreshTools = (): void => {
96
+ void props.toolsList().then(
97
+ (next) => { setTools(next); setToolsError(null) },
98
+ (error) => {
99
+ console.error('[dsh-mcp] toolsList failed:', error)
100
+ setToolsError(String((error instanceof Error ? error.message : error) ?? error))
101
+ setTools(null)
102
+ },
103
+ )
104
+ }
105
+
106
+ const fail = (error: unknown): void => {
107
+ // The settings shell hides Remote failures behind a generic copy; surface
108
+ // the real message here so a broken list() is diagnosable from the page.
109
+ console.error('[dsh-mcp] list failed:', error)
110
+ setLoadErrorDetail(String((error instanceof Error ? error.message : error) ?? error))
111
+ setLoadState('error')
112
+ }
113
+
114
+ const load = (): void => {
115
+ void list().then(
116
+ (servers) => {
117
+ setServers(servers)
118
+ setLoadState('ready')
119
+ setLoadErrorDetail(null)
120
+ },
121
+ (error) => fail(error),
122
+ )
123
+ refreshTools()
124
+ }
125
+
126
+ useEffect(() => {
127
+ let current = true
128
+ void list().then(
129
+ (servers) => {
130
+ if (!current) return
131
+ setServers(servers)
132
+ setLoadState('ready')
133
+ setLoadErrorDetail(null)
134
+ },
135
+ (error) => { if (current) fail(error) },
136
+ )
137
+ void props.toolsList().then(
138
+ (next) => { if (current) { setTools(next); setToolsError(null) } },
139
+ (error) => {
140
+ if (!current) return
141
+ console.error('[dsh-mcp] toolsList failed:', error)
142
+ setToolsError(String((error instanceof Error ? error.message : error) ?? error))
143
+ setTools(null)
144
+ },
145
+ )
146
+ return () => { current = false }
147
+ }, [list, setLoadState, setServers])
148
+
149
+ const setMode = (mode: McpToolInjectionMode): void => {
150
+ void props.toolsMode({ mode }).then(refreshTools, (error) => {
151
+ console.error('[dsh-mcp] toolsMode failed:', error)
152
+ refreshTools()
153
+ })
154
+ }
155
+
156
+ const toggleTool = (tool: McpToolView): void => {
157
+ const next = !tool.enabled
158
+ // Optimistic local toggle; the Host is the source of truth.
159
+ setTools(prev => prev === null
160
+ ? prev
161
+ : { ...prev, tools: prev.tools.map(item => item.name === tool.name ? { ...item, enabled: next } : item) })
162
+ void props.toolsSet({ name: tool.name, enabled: next }).catch((error) => {
163
+ console.error('[dsh-mcp] toolsSet failed:', error)
164
+ refreshTools()
165
+ })
166
+ }
167
+
168
+ const refreshServer = (serverName: string): void => {
169
+ setRefreshing(prev => new Set(prev).add(serverName))
170
+ void Promise.all([
171
+ list().then(
172
+ (servers) => { setServers(servers); setLoadState('ready') },
173
+ () => {},
174
+ ),
175
+ props.toolsList().then(setTools, () => {}),
176
+ ]).finally(() => {
177
+ setRefreshing(prev => {
178
+ const next = new Set(prev)
179
+ next.delete(serverName)
180
+ return next
181
+ })
182
+ })
183
+ }
184
+
185
+ const toggleExpand = (serverName: string): void => {
186
+ setExpanded(prev => {
187
+ const next = new Set(prev)
188
+ if (next.has(serverName)) next.delete(serverName)
189
+ else next.add(serverName)
190
+ return next
191
+ })
192
+ }
193
+
194
+ const toggleEnabled = async (server: McpServerView): Promise<void> => {
195
+ const target = !server.enabled
196
+ const draft = viewToDraft(server)
197
+ draft.enabled = target
198
+ try {
199
+ const failure = await save(draft)
200
+ if (failure !== null) {
201
+ console.error('[dsh-mcp] setEnabled failed:', failure.message)
202
+ return
203
+ }
204
+ await refreshServer(server.serverName)
205
+ if (target) {
206
+ // Mounting is asynchronous: refresh again after the connection settles
207
+ // so the badge and tool count reflect the live state without a manual
208
+ // refresh.
209
+ timersRef.current.push(window.setTimeout(() => refreshServer(server.serverName), 2000))
210
+ }
211
+ } catch (error) {
212
+ console.error('[dsh-mcp] setEnabled failed:', error)
213
+ }
214
+ }
215
+
216
+ const serverTools = (serverName: string): McpToolView[] =>
217
+ (tools?.tools ?? []).filter(tool => tool.server === serverName)
218
+
219
+ const mode = tools?.mode ?? 'search'
220
+
221
+ if (state.loadState === 'loading') {
222
+ return <p className={css.status} aria-busy="true">{t('loading')}</p>
223
+ }
224
+ if (state.loadState === 'error') {
225
+ return (
226
+ <div className={css.failure}>
227
+ <p role="alert">{t('loadError')}</p>
228
+ {loadErrorDetail !== null ? <pre className={css.muted}>{loadErrorDetail}</pre> : null}
229
+ <button type="button" onClick={() => { setLoadState('loading'); load() }}>{t('retry')}</button>
230
+ </div>
231
+ )
232
+ }
233
+ if (state.draft !== null) {
234
+ const actions = {
235
+ updateDraft,
236
+ cancelEdit,
237
+ setBusy,
238
+ setTestRunning,
239
+ setTest,
240
+ setServers,
241
+ }
242
+ return (
243
+ <McpServerForm
244
+ draft={state.draft}
245
+ busy={state.busy}
246
+ testRunning={state.testRunning}
247
+ test={state.test}
248
+ t={t}
249
+ actions={actions}
250
+ injected={{ save, remove, test, list }}
251
+ />
252
+ )
253
+ }
254
+
255
+ return (
256
+ <div className={css.section}>
257
+ <div className={css.header}>
258
+ <button type="button" className={css.primary} onClick={beginCreate}>{t('addServer')}</button>
259
+ </div>
260
+
261
+ <div className={css.modeRow}>
262
+ <span className={css.modeLabel}>{t('toolsModeLabel')}</span>
263
+ <label className={css.modeOption}>
264
+ <input
265
+ type="radio"
266
+ name="mcp-tool-mode"
267
+ checked={mode === 'search'}
268
+ onChange={() => setMode('search')}
269
+ />
270
+ {t('toolsModeSearch')}
271
+ </label>
272
+ <label className={css.modeOption}>
273
+ <input
274
+ type="radio"
275
+ name="mcp-tool-mode"
276
+ checked={mode === 'full'}
277
+ onChange={() => setMode('full')}
278
+ />
279
+ {t('toolsModeFull')}
280
+ </label>
281
+ </div>
282
+ <p className={css.modeHint}>
283
+ {toolsError !== null ? toolsError : tools === null ? t('toolsLoading') : mode === 'search' ? t('toolsHintSearch') : t('toolsHintFull')}
284
+ </p>
285
+
286
+ {state.servers.length === 0 ? (
287
+ <p className={css.status}>{t('empty')}</p>
288
+ ) : (
289
+ <ul className={css.list}>
290
+ {state.servers.map(server => {
291
+ const isExpanded = expanded.has(server.serverName)
292
+ const isRefreshing = refreshing.has(server.serverName)
293
+ const serverToolList = serverTools(server.serverName)
294
+ return (
295
+ <li key={server.id} className={css.card}>
296
+ <div className={css.row}>
297
+ <div className={css.rowMain}>
298
+ <div className={css.rowTitle}>
299
+ <span className={css.serverName}>{server.serverName}</span>
300
+ <span className={`${css.badge} ${css[server.status.phase]}`}>{t(phaseKey(server.status.phase))}</span>
301
+ {!server.enabled ? <span className={css.muted}>{t('statusStopped')}</span> : null}
302
+ </div>
303
+ <div className={css.rowMeta}>
304
+ <span>{server.transport}</span>
305
+ <span>{t('toolCount')}: {server.status.tools.length}</span>
306
+ <span>{t('envVars')}: {server.env.length}</span>
307
+ </div>
308
+ </div>
309
+ <div className={css.rowActions}>
310
+ <button type="button" disabled={isRefreshing} onClick={() => void toggleEnabled(server)}>
311
+ {server.enabled ? t('disable') : t('enabled')}
312
+ </button>
313
+ <button type="button" disabled={isRefreshing} onClick={() => refreshServer(server.serverName)}>
314
+ {isRefreshing ? t('refreshing') : t('refresh')}
315
+ </button>
316
+ <button type="button" onClick={() => toggleExpand(server.serverName)}>
317
+ {isExpanded ? t('toolsCollapse') : t('toolsExpand')}
318
+ </button>
319
+ <button type="button" onClick={() => beginEdit(server)}>{t('edit')}</button>
320
+ </div>
321
+ </div>
322
+ {isExpanded ? (
323
+ <div className={css.toolPanel}>
324
+ {tools === null ? (
325
+ <p className={css.muted}>{toolsError ?? t('toolsLoading')}</p>
326
+ ) : serverToolList.length === 0 ? (
327
+ <p className={css.muted}>{t('toolsEmpty')}</p>
328
+ ) : (
329
+ <div className={css.toolGrid}>
330
+ {serverToolList.map(tool => (
331
+ <label key={tool.name} className={css.toolRow} title={tool.name}>
332
+ <input
333
+ type="checkbox"
334
+ checked={tool.enabled}
335
+ onChange={() => toggleTool(tool)}
336
+ />
337
+ <span className={css.toolName}>{rawOf(tool.name)}</span>
338
+ </label>
339
+ ))}
340
+ </div>
341
+ )}
342
+ </div>
343
+ ) : null}
344
+ </li>
345
+ )
346
+ })}
347
+ </ul>
348
+ )}
349
+ </div>
350
+ )
351
+ }
@@ -0,0 +1,196 @@
1
+ .form {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 14px;
5
+ width: 100%;
6
+ max-width: 760px;
7
+ color: var(--dsw-alias-label-primary);
8
+ }
9
+
10
+ .titleRow {
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: space-between;
14
+ gap: 12px;
15
+ }
16
+
17
+ .title {
18
+ margin: 0;
19
+ font-size: 15px;
20
+ line-height: 22px;
21
+ font-weight: 600;
22
+ }
23
+
24
+ .titleActions {
25
+ display: flex;
26
+ gap: 8px;
27
+ }
28
+
29
+ button {
30
+ border: 1px solid var(--dsw-alias-border-l2);
31
+ border-radius: 6px;
32
+ padding: 5px 12px;
33
+ background: transparent;
34
+ color: var(--dsw-alias-label-primary);
35
+ font: inherit;
36
+ font-size: 13px;
37
+ line-height: 20px;
38
+ cursor: pointer;
39
+ }
40
+
41
+ button:hover {
42
+ background: var(--dsw-alias-interactive-bg-hover);
43
+ }
44
+
45
+ button:focus-visible {
46
+ outline: 2px solid var(--dsw-alias-state-business-primary);
47
+ outline-offset: 1px;
48
+ }
49
+
50
+ button:disabled {
51
+ cursor: not-allowed;
52
+ opacity: 0.55;
53
+ }
54
+
55
+ .primary {
56
+ border-color: var(--dsw-alias-state-business-primary);
57
+ background: var(--dsw-alias-state-business-primary);
58
+ color: var(--dsw-alias-label-on-accent);
59
+ }
60
+
61
+ .primary:hover {
62
+ background: var(--dsw-alias-state-business-primary-hover);
63
+ }
64
+
65
+ .danger {
66
+ color: var(--dsw-alias-state-error-primary);
67
+ }
68
+
69
+ .error {
70
+ margin: 0;
71
+ color: var(--dsw-alias-state-error-primary);
72
+ font-size: 13px;
73
+ line-height: 20px;
74
+ }
75
+
76
+ .field {
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 5px;
80
+ font-size: 13px;
81
+ line-height: 20px;
82
+ color: var(--dsw-alias-label-secondary);
83
+ }
84
+
85
+ .field input,
86
+ .field select,
87
+ .field textarea {
88
+ width: 100%;
89
+ box-sizing: border-box;
90
+ border: 1px solid var(--dsw-alias-border-l2);
91
+ border-radius: 8px;
92
+ padding: 6px 10px;
93
+ outline: none;
94
+ background: var(--dsw-alias-bg-layer-1);
95
+ color: var(--dsw-alias-label-primary);
96
+ font: inherit;
97
+ font-size: 13px;
98
+ resize: vertical;
99
+ }
100
+
101
+ .field input::placeholder,
102
+ .field textarea::placeholder {
103
+ color: var(--dsw-alias-label-tertiary);
104
+ }
105
+
106
+ .field input:focus-visible,
107
+ .field select:focus-visible,
108
+ .field textarea:focus-visible {
109
+ border-color: var(--dsw-alias-state-business-primary);
110
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);
111
+ }
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
+ .hint,
131
+ .muted {
132
+ margin: 0;
133
+ font-size: 12px;
134
+ line-height: 18px;
135
+ color: var(--dsw-alias-label-tertiary);
136
+ }
137
+
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
+ .checkRow {
169
+ display: inline-flex;
170
+ align-items: center;
171
+ gap: 8px;
172
+ font-size: 13px;
173
+ line-height: 20px;
174
+ color: var(--dsw-alias-label-secondary);
175
+ cursor: pointer;
176
+ }
177
+
178
+ .testResult {
179
+ margin: 0;
180
+ font-size: 13px;
181
+ line-height: 20px;
182
+ }
183
+
184
+ .testOk {
185
+ color: var(--dsw-alias-state-success-primary);
186
+ }
187
+
188
+ .testFail {
189
+ color: var(--dsw-alias-state-error-primary);
190
+ }
191
+
192
+ .actions {
193
+ display: flex;
194
+ justify-content: flex-end;
195
+ gap: 8px;
196
+ }