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,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
+ }