dsh-mcp-panel 0.2.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.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.es.md +129 -0
  3. package/README.hi.md +129 -0
  4. package/README.md +129 -0
  5. package/README.pt.md +129 -0
  6. package/README.zh.md +129 -0
  7. package/cordis.patch.yml +23 -0
  8. package/lib/client.js +5045 -0
  9. package/lib/client.js.map +1 -0
  10. package/lib/index.js +1079 -0
  11. package/lib/typert.host.js +4261 -0
  12. package/lib/types/aggregate.d.ts +92 -0
  13. package/lib/types/aggregate.d.ts.map +1 -0
  14. package/lib/types/client/McpPanelTab.d.ts +16 -0
  15. package/lib/types/client/McpPanelTab.d.ts.map +1 -0
  16. package/lib/types/client/index.d.ts +35 -0
  17. package/lib/types/client/index.d.ts.map +1 -0
  18. package/lib/types/client/locales.d.ts +90 -0
  19. package/lib/types/client/locales.d.ts.map +1 -0
  20. package/lib/types/client/present.d.ts +79 -0
  21. package/lib/types/client/present.d.ts.map +1 -0
  22. package/lib/types/client/remote.d.ts +119 -0
  23. package/lib/types/client/remote.d.ts.map +1 -0
  24. package/lib/types/client/styles.d.ts +12 -0
  25. package/lib/types/client/styles.d.ts.map +1 -0
  26. package/lib/types/command.d.ts +122 -0
  27. package/lib/types/command.d.ts.map +1 -0
  28. package/lib/types/config.d.ts +63 -0
  29. package/lib/types/config.d.ts.map +1 -0
  30. package/lib/types/grouping.d.ts +49 -0
  31. package/lib/types/grouping.d.ts.map +1 -0
  32. package/lib/types/index.d.ts +41 -0
  33. package/lib/types/index.d.ts.map +1 -0
  34. package/lib/types/probe.d.ts +67 -0
  35. package/lib/types/probe.d.ts.map +1 -0
  36. package/lib/types/sanitize.d.ts +42 -0
  37. package/lib/types/sanitize.d.ts.map +1 -0
  38. package/lib/types/service.d.ts +107 -0
  39. package/lib/types/service.d.ts.map +1 -0
  40. package/lib/types/typert.host.d.ts +110 -0
  41. package/lib/types/typert.host.d.ts.map +1 -0
  42. package/lib/types/upstream.d.ts +67 -0
  43. package/lib/types/upstream.d.ts.map +1 -0
  44. package/lib/types/wire.d.ts +342 -0
  45. package/lib/types/wire.d.ts.map +1 -0
  46. package/package.json +111 -0
  47. package/src/aggregate.ts +248 -0
  48. package/src/client/McpPanelTab.tsx +257 -0
  49. package/src/client/index.ts +87 -0
  50. package/src/client/locales.ts +92 -0
  51. package/src/client/present.ts +127 -0
  52. package/src/client/remote.ts +35 -0
  53. package/src/client/styles.ts +235 -0
  54. package/src/command.ts +387 -0
  55. package/src/config.ts +110 -0
  56. package/src/grouping.ts +109 -0
  57. package/src/index.ts +86 -0
  58. package/src/probe.ts +198 -0
  59. package/src/sanitize.ts +115 -0
  60. package/src/service.ts +279 -0
  61. package/src/typert.host.ts +25 -0
  62. package/src/upstream.ts +72 -0
  63. package/src/wire.ts +221 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Status aggregation: assemble the read-only server views from the three
3
+ * facts the panel is allowed to read — loader rows (config, effective
4
+ * disabled, fiber phase), the tool-registry snapshot, and upstream
5
+ * `mcp/status` observations (when the proposed seam exists).
6
+ *
7
+ * Every field is read defensively: loader configs are raw serialized data
8
+ * (possibly `!!js` expressions, wrong types, or absent), and upstream
9
+ * payloads may lack optional fields. A malformed or missing field degrades
10
+ * to an explicit default instead of throwing — the aggregation must never
11
+ * take down the panel over one broken row.
12
+ *
13
+ * Pure: statuses and reconnect counters are passed in as snapshots.
14
+ *
15
+ * @module dsh-mcp-panel/aggregate
16
+ */
17
+
18
+ import { groupMcpTools, type McpToolGroup } from './grouping.ts'
19
+ import { sanitizeError, sanitizeUrl } from './sanitize.ts'
20
+ import type { McpServerStatus } from './upstream.ts'
21
+ import type {
22
+ McpConnectionPhase,
23
+ McpFiberPhase,
24
+ McpPanelSnapshot,
25
+ McpServerView,
26
+ McpTransport,
27
+ } from './wire.ts'
28
+
29
+ /** One loader-derived mcp-client row (config is raw serialized data). */
30
+ export interface McpLoaderRow {
31
+ /** Loader entry id (the patch row id). */
32
+ entryId: string
33
+ /** Effective disabled state (parent groups and `!!js` already resolved by the Loader). */
34
+ disabled: boolean
35
+ /** Fiber phase; null when the row has no fiber. */
36
+ fiberPhase: McpFiberPhase
37
+ /** Raw `config` from the entry options; may be anything. */
38
+ config: unknown
39
+ }
40
+
41
+ /** The exact module name of the official MCP client bridge. */
42
+ export const MCP_CLIENT_MODULE = '@deepseek-ai/dsh-mcp-client'
43
+
44
+ /** Marker shown for a `!!js` config value, which the panel never evaluates. */
45
+ export const JS_EXPRESSION_MARKER = '<expression>'
46
+
47
+ /** Sentinel values for "not observed" numeric fields. */
48
+ export const UNKNOWN_COUNT = -1
49
+
50
+ /** One server namespace → its upstream observation and derived totals. */
51
+ export interface McpStatusFacts {
52
+ /** Latest upstream payload per server; absent = not observed. */
53
+ statuses: ReadonlyMap<string, McpServerStatus>
54
+ /** Cumulative reconnect attempts observed per server. */
55
+ reconnects: ReadonlyMap<string, number>
56
+ /** Epoch ms of the latest upstream event receipt per server. */
57
+ observedAt: ReadonlyMap<string, number>
58
+ /** Passive-probe reachability facts per server (empty when probing is off). */
59
+ probeStates: ReadonlyMap<string, { state: 'reachable' | 'unreachable'; checkedAt: number }>
60
+ }
61
+
62
+ /**
63
+ * The raw face of a `!!js` expression node in serialized loader config.
64
+ * Detected structurally; the expression is never evaluated or displayed.
65
+ */
66
+ interface JsExprNode {
67
+ readonly __jsExpr?: unknown
68
+ }
69
+
70
+ /** Read a plain JSON value from raw config; `!!js` nodes and wrong types become the fallback. */
71
+ function plainField(config: unknown, key: string): unknown {
72
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) return undefined
73
+ const value = (config as Record<string, unknown>)[key]
74
+ if (value === undefined || value === null) return undefined
75
+ if (typeof value === 'object' && '__jsExpr' in (value as JsExprNode)) return undefined
76
+ return value
77
+ }
78
+
79
+ /** Read a string config field; absent, non-string, or `!!js` becomes the fallback. */
80
+ function stringField(config: unknown, key: string, fallback: string): string {
81
+ const value = plainField(config, key)
82
+ return typeof value === 'string' ? value : fallback
83
+ }
84
+
85
+ /** Render one argument for the display command line. */
86
+ function renderArg(arg: unknown): string {
87
+ if (typeof arg === 'string') {
88
+ return /^[\w./:@%+=,_-]+$/u.test(arg) ? arg : JSON.stringify(arg)
89
+ }
90
+ if (typeof arg === 'object' && arg !== null && '__jsExpr' in (arg as JsExprNode)) {
91
+ return JS_EXPRESSION_MARKER
92
+ }
93
+ try {
94
+ return JSON.stringify(arg)
95
+ } catch {
96
+ return JS_EXPRESSION_MARKER
97
+ }
98
+ }
99
+
100
+ /** Transport and display target derived from one raw mcp-client config. */
101
+ export function deriveTarget(config: unknown): { transport: McpTransport; target: string } {
102
+ const transport = stringField(config, 'transport', '')
103
+ if (transport === 'stdio') {
104
+ const command = stringField(config, 'command', '')
105
+ if (command === '') return { transport: 'unknown', target: '(unconfigured)' }
106
+ const argsValue = plainField(config, 'args')
107
+ const args = Array.isArray(argsValue) ? argsValue.map(renderArg) : []
108
+ const target = args.length === 0 ? command : `${command} ${args.join(' ')}`
109
+ return { transport: 'stdio', target }
110
+ }
111
+ if (transport === 'streamable-http') {
112
+ const url = stringField(config, 'url', '')
113
+ if (url === '') return { transport: 'unknown', target: '(unconfigured)' }
114
+ return { transport: 'streamable-http', target: sanitizeUrl(url) }
115
+ }
116
+ return { transport: 'unknown', target: '(unconfigured)' }
117
+ }
118
+
119
+ /** Read the server namespace from raw config; absent becomes a stable fallback. */
120
+ export function serverNameOf(config: unknown, fallback: string): string {
121
+ const name = stringField(config, 'serverName', '')
122
+ return name === '' ? fallback : name
123
+ }
124
+
125
+ /**
126
+ * Derive the config-declared policy facts in one display line. Reads only
127
+ * boolean and number fields (never strings from raw config), so nothing
128
+ * user-supplied or secret can leak; `null` = nothing noteworthy configured.
129
+ */
130
+ function configuredNote(config: unknown): string | null {
131
+ const parts: string[] = []
132
+ const reconnectValue = plainField(config, 'reconnect')
133
+ if (typeof reconnectValue === 'object' && reconnectValue !== null && !Array.isArray(reconnectValue)) {
134
+ const enabled = plainField(reconnectValue, 'enabled')
135
+ const maxAttempts = plainField(reconnectValue, 'maxAttempts')
136
+ if (typeof enabled === 'boolean' && !enabled) {
137
+ parts.push('reconnect off')
138
+ } else if (typeof maxAttempts === 'number' && Number.isFinite(maxAttempts)) {
139
+ parts.push(`reconnect max ${maxAttempts}`)
140
+ }
141
+ }
142
+ if (plainField(config, 'failOnStartupError') === true) parts.push('fail on startup error')
143
+ const toolTimeout = plainField(config, 'toolCallTimeoutMs')
144
+ if (typeof toolTimeout === 'number' && Number.isFinite(toolTimeout)) parts.push(`tool timeout ${Math.round(toolTimeout / 1000)}s`)
145
+ return parts.length === 0 ? null : parts.join('; ')
146
+ }
147
+
148
+ /** Upstream phase projected onto the wire vocabulary (unknown when unobserved). */
149
+ function connectionPhase(status: McpServerStatus | undefined): McpConnectionPhase {
150
+ if (status === undefined) return 'unknown'
151
+ return status.phase
152
+ }
153
+
154
+ /**
155
+ * Assemble one server view from loader, registry, and upstream facts.
156
+ * Missing upstream data degrades to `unknown`/`-1`/`null` — never fabricated.
157
+ *
158
+ * @param row - the mcp-client loader row, or `undefined` for leftover namespaces.
159
+ * @param serverName - the effective namespace.
160
+ * @param group - the tool group for this namespace (possibly empty).
161
+ * @param facts - upstream observations and reconnect totals.
162
+ * @returns the display-ready view.
163
+ */
164
+ export function aggregateServerView(
165
+ row: McpLoaderRow | undefined,
166
+ serverName: string,
167
+ group: McpToolGroup | undefined,
168
+ facts: McpStatusFacts,
169
+ ): McpServerView {
170
+ const status = facts.statuses.get(serverName)
171
+ const { transport, target } = row === undefined ? { transport: 'unknown' as const, target: '(unconfigured)' } : deriveTarget(row.config)
172
+ const lastError = status?.error === undefined ? null : sanitizeError(status.error)
173
+ const attempt = status?.attempt ?? UNKNOWN_COUNT
174
+ const maxAttempts = status?.maxAttempts ?? UNKNOWN_COUNT
175
+ const reconnect = facts.reconnects.get(serverName) ?? UNKNOWN_COUNT
176
+ const connectedAt = status?.connectedAt ?? null
177
+ const delayMs = status?.delayMs ?? null
178
+ const observedAt = facts.observedAt.get(serverName) ?? null
179
+ const probe = facts.probeStates.get(serverName)
180
+ return {
181
+ serverName,
182
+ entryId: row?.entryId ?? '',
183
+ transport,
184
+ target,
185
+ enabled: row?.disabled === false,
186
+ fiberPhase: row?.fiberPhase ?? null,
187
+ configuredNote: row === undefined ? null : configuredNote(row.config),
188
+ toolCount: group?.tools.length ?? 0,
189
+ tools: group?.tools ?? [],
190
+ phase: connectionPhase(status),
191
+ attempt,
192
+ maxAttempts,
193
+ delayMs,
194
+ reconnectCount: reconnect,
195
+ lastError,
196
+ connectedAt,
197
+ observedAt,
198
+ probeState: probe?.state ?? null,
199
+ probeCheckedAt: probe?.checkedAt ?? null,
200
+ statusSource: status === undefined ? 'derived' : 'upstream-event',
201
+ }
202
+ }
203
+
204
+ /** Inputs for {@link aggregateSnapshot}; grouped so callers never mix them up. */
205
+ export interface McpAggregateInput {
206
+ /** Loader mcp-client rows (raw config included). */
207
+ rows: readonly McpLoaderRow[]
208
+ /** Tool groups from {@link groupMcpTools}. */
209
+ groups: readonly McpToolGroup[]
210
+ /** Upstream status facts (may be empty). */
211
+ facts: McpStatusFacts
212
+ /** Background probe views (may be empty). */
213
+ probes: McpPanelSnapshot['probes']
214
+ /** Absolute profile patch-layer path, or null. */
215
+ patchFile: string | null
216
+ /** Suggested panel refresh interval in ms (`0` = on demand). */
217
+ refreshIntervalMs: number
218
+ }
219
+
220
+ /**
221
+ * Assemble the complete snapshot from loader rows, tool groups, upstream
222
+ * facts, and probe rows. Tolerates missing fields anywhere in the inputs.
223
+ *
224
+ * @param input - the snapshot inputs (see {@link McpAggregateInput}).
225
+ * @returns the wire snapshot.
226
+ */
227
+ export function aggregateSnapshot(input: McpAggregateInput): McpPanelSnapshot {
228
+ const { rows, groups, facts, probes, patchFile, refreshIntervalMs } = input
229
+ // One view per namespace: the enabled row wins; otherwise the first row.
230
+ const rowsByName = new Map<string, McpLoaderRow>()
231
+ for (const row of rows) {
232
+ const name = serverNameOf(row.config, `entry:${row.entryId}`)
233
+ const existing = rowsByName.get(name)
234
+ if (existing === undefined || (!row.disabled && existing.disabled)) rowsByName.set(name, row)
235
+ }
236
+ const groupsByName = new Map(groups.map(group => [group.serverName, group]))
237
+ const names = new Set([...rowsByName.keys(), ...groupsByName.keys()])
238
+ const servers = [...names]
239
+ .map(name => aggregateServerView(rowsByName.get(name), name, groupsByName.get(name), facts))
240
+ .sort((left, right) => left.serverName < right.serverName ? -1 : 1)
241
+ return {
242
+ observed: facts.statuses.size > 0,
243
+ patchFile,
244
+ refreshIntervalMs,
245
+ servers,
246
+ probes,
247
+ }
248
+ }
@@ -0,0 +1,257 @@
1
+ /** The read-only MCP management tab: server rows, badges, tools, probes. */
2
+
3
+ import { useEffect, useId, useState, type ReactNode } from 'react'
4
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
5
+ import { presentMcpPanel, probeBadge, type BadgeTone, type PresentedServerRow } from './present.ts'
6
+ import type { McpPanelSnapshot, McpProbeView, ProbeStarted } from '../wire.ts'
7
+
8
+ /** Registration-side injected face: the unwrapped snapshot read + probe start. */
9
+ export interface McpPanelTabInjected {
10
+ /** Read the current Host snapshot (RemoteResult already unwrapped). */
11
+ status: () => Promise<McpPanelSnapshot>
12
+ /** Start a one-shot probe of one streamable-http server (panel-only result). */
13
+ probe: (serverName: string) => Promise<ProbeStarted>
14
+ }
15
+
16
+ /** Full component props assembled by the Settings slot renderer. */
17
+ export type McpPanelTabProps =
18
+ PropsRuntime<'settings.plugins.tab'>
19
+ & PropsLocale<'settings.mcpPanel'>
20
+ & InjectFace<McpPanelTabInjected>
21
+
22
+ type ViewState =
23
+ | { readonly status: 'loading' }
24
+ | { readonly status: 'error' }
25
+ | { readonly status: 'ready'; readonly snapshot: McpPanelSnapshot }
26
+
27
+ /** Localized label for one server badge code. */
28
+ function badgeLabel(badge: PresentedServerRow['badge'], t: McpPanelTabProps['t']): string {
29
+ switch (badge) {
30
+ case 'disabled': return t('statusDisabled')
31
+ case 'failed': return t('statusFailed')
32
+ case 'connecting': return t('statusConnecting')
33
+ case 'connected': return t('statusConnected')
34
+ case 'waiting': return t('statusWaiting')
35
+ case 'exhausted': return t('statusExhausted')
36
+ case 'disposed': return t('statusDisposed')
37
+ default: return t('statusUnknown')
38
+ }
39
+ }
40
+
41
+ /** Localized label for one probe badge code. */
42
+ function probeLabel(badge: ReturnType<typeof probeBadge>['badge'], t: McpPanelTabProps['t']): string {
43
+ switch (badge) {
44
+ case 'running': return t('probeRunning')
45
+ case 'completed': return t('probeCompleted')
46
+ case 'failed': return t('probeFailed')
47
+ case 'killed': return t('probeKilled')
48
+ case 'stopping': return t('probeStopping')
49
+ }
50
+ }
51
+
52
+ /** Local wall-clock range for one probe row; component-layer formatting only. */
53
+ function formatProbeTime(view: McpProbeView): string {
54
+ const format = (ms: number): string => new Date(ms).toLocaleTimeString(undefined, { hour12: false })
55
+ const start = format(view.startedAt)
56
+ return view.finishedAt === null ? start : `${start}–${format(view.finishedAt)}`
57
+ }
58
+
59
+ /** Render the read-only MCP management tab. */
60
+ export function McpPanelTab({ status, probe, t }: McpPanelTabProps): ReactNode {
61
+ const listId = useId()
62
+ const [request, setRequest] = useState(0)
63
+ const [expanded, setExpanded] = useState<string | null>(null)
64
+ const [probeError, setProbeError] = useState<string | null>(null)
65
+ const [state, setState] = useState<ViewState>({ status: 'loading' })
66
+ // Per-card tool filter: one query per server so expanding two cards at once
67
+ // never filters one card by the other card's search text.
68
+ const [toolQueries, setToolQueries] = useState<Record<string, string>>({})
69
+
70
+ const reload = (): void => {
71
+ setRequest(value => value + 1)
72
+ }
73
+
74
+ useEffect(() => {
75
+ let current = true
76
+ void Promise.resolve().then(() => status()).then(
77
+ (snapshot) => { if (current) setState({ status: 'ready', snapshot }) },
78
+ () => { if (current) setState({ status: 'error' }) },
79
+ )
80
+ return () => { current = false }
81
+ }, [status, request])
82
+
83
+ // Optional polling: the host suggests an interval through the snapshot
84
+ // (0 = on demand only). Errors during polling keep the last good snapshot.
85
+ // Polling pauses while the document is hidden and refreshes immediately on
86
+ // becoming visible again, so background tabs neither spin nor show stale data.
87
+ const intervalMs = state.status === 'ready' ? state.snapshot.refreshIntervalMs : 0
88
+ useEffect(() => {
89
+ if (intervalMs <= 0) return undefined
90
+ const tick = (): void => { if (!document.hidden) reload() }
91
+ const timer = setInterval(tick, intervalMs)
92
+ const onVisible = (): void => { if (!document.hidden) reload() }
93
+ document.addEventListener('visibilitychange', onVisible)
94
+ return () => {
95
+ clearInterval(timer)
96
+ document.removeEventListener('visibilitychange', onVisible)
97
+ }
98
+ }, [intervalMs])
99
+
100
+ const retry = (): void => {
101
+ setState({ status: 'loading' })
102
+ reload()
103
+ }
104
+
105
+ const model = state.status === 'ready' ? presentMcpPanel(state.snapshot) : undefined
106
+
107
+ return (
108
+ <div className="dmcp-section" data-dsh-mcp-panel="" aria-busy={state.status === 'loading'}>
109
+ {state.status === 'loading' ? <p className="dmcp-status">{t('loading')}</p> : null}
110
+ {state.status === 'error' ? (
111
+ <div className="dmcp-failure">
112
+ <p role="alert">{t('error')}</p>
113
+ <button type="button" onClick={retry}>{t('retry')}</button>
114
+ </div>
115
+ ) : null}
116
+ {model !== undefined ? (
117
+ <div className="dmcp-panel">
118
+ {model.empty ? <p className="dmcp-status">{t('empty')}</p> : (
119
+ <>
120
+ <h3 className="dmcp-heading">{t('servers')}</h3>
121
+ <ul className="dmcp-cards">
122
+ {model.servers.map((row) => {
123
+ const open = expanded === row.view.serverName
124
+ const detailId = `${listId}-${encodeURIComponent(row.view.serverName)}`
125
+ const toolQuery = toolQueries[row.view.serverName] ?? ''
126
+ const probeRunning = model.probes.some(
127
+ probe => probe.view.status === 'running' && probe.view.serverName === row.view.serverName,
128
+ )
129
+ return (
130
+ <li className="dmcp-card" key={row.view.serverName} data-mcp-server={row.view.serverName} data-open={open ? 'true' : undefined}>
131
+ <button
132
+ type="button"
133
+ className="dmcp-card-content"
134
+ aria-expanded={open}
135
+ aria-controls={detailId}
136
+ onClick={() => { setExpanded(current => current === row.view.serverName ? null : row.view.serverName) }}
137
+ >
138
+ <strong className="dmcp-card-title">{row.view.serverName}</strong>
139
+ <span className="dmcp-card-trailing">
140
+ {row.view.probeState !== null ? (
141
+ <Badge
142
+ tone={row.view.probeState === 'reachable' ? 'ok' : 'error'}
143
+ label={row.view.probeState === 'reachable' ? t('probeReachable') : t('probeUnreachable')}
144
+ />
145
+ ) : null}
146
+ <Badge tone={row.tone} label={badgeLabel(row.badge, t)} />
147
+ <span className="dmcp-tool-count">
148
+ {row.view.toolCount} {t('tools')}
149
+ </span>
150
+ </span>
151
+ </button>
152
+ {open ? (
153
+ <div className="dmcp-card-details" id={detailId}>
154
+ <dl className="dmcp-details">
155
+ <div><dt>{t('status')}</dt><dd>{badgeLabel(row.badge, t)}</dd></div>
156
+ {row.hasAttemptBudget ? (
157
+ <div>
158
+ <dt>{t('attempt')}</dt>
159
+ <dd>{row.view.attempt < 0 ? t('none') : row.view.attempt}/{row.view.maxAttempts < 0 ? t('none') : row.view.maxAttempts}</dd>
160
+ </div>
161
+ ) : null}
162
+ <div><dt>{t('reconnects')}</dt><dd>{row.reconnects ?? t('none')}</dd></div>
163
+ <div><dt>{t('lastError')}</dt><dd className={row.hasError ? 'dmcp-error-text' : undefined}>{row.view.lastError ?? t('none')}</dd></div>
164
+ <div><dt>{t('fiber')}</dt><dd>{row.view.fiberPhase ?? t('none')}</dd></div>
165
+ {row.view.configuredNote !== null ? <div><dt>{t('configured')}</dt><dd>{row.view.configuredNote}</dd></div> : null}
166
+ {row.ageSeconds !== null ? <div><dt>{t('lastEvent')}</dt><dd>{row.ageSeconds}s</dd></div> : null}
167
+ {row.view.delayMs !== null ? <div><dt>{t('retryIn')}</dt><dd>{row.view.delayMs} {t('ms')}</dd></div> : null}
168
+ </dl>
169
+ <code className="dmcp-target" title={row.view.target}>{row.view.transport} {row.view.target}</code>
170
+ {row.view.transport === 'streamable-http' ? (
171
+ <button
172
+ type="button"
173
+ className="dmcp-probe-now"
174
+ disabled={probeRunning}
175
+ onClick={() => {
176
+ setProbeError(null)
177
+ void Promise.resolve().then(() => probe(row.view.serverName)).then(
178
+ () => { reload() },
179
+ (error: unknown) => { setProbeError(error instanceof Error ? error.message : String(error)) },
180
+ )
181
+ }}
182
+ >
183
+ {probeRunning ? t('probeRunning') : t('probeNow')}
184
+ </button>
185
+ ) : null}
186
+ {row.view.tools.length === 0 ? (
187
+ <p className="dmcp-status">{t('noTools')}</p>
188
+ ) : (
189
+ <>
190
+ <input
191
+ type="search"
192
+ className="dmcp-tool-filter"
193
+ value={toolQuery}
194
+ placeholder={t('filterTools')}
195
+ aria-label={t('filterTools')}
196
+ onChange={(event) => {
197
+ setToolQueries(current => ({ ...current, [row.view.serverName]: event.currentTarget.value }))
198
+ }}
199
+ />
200
+ <ul className="dmcp-tools">
201
+ {row.view.tools
202
+ .filter(tool => toolQuery.trim() === ''
203
+ || tool.name.toLocaleLowerCase().includes(toolQuery.trim().toLocaleLowerCase())
204
+ || tool.description.toLocaleLowerCase().includes(toolQuery.trim().toLocaleLowerCase()))
205
+ .map(tool => (
206
+ <li key={tool.name}>
207
+ <code>{tool.name}</code>
208
+ {tool.description !== '' ? <span className="dmcp-tool-description">{tool.description}</span> : null}
209
+ </li>
210
+ ))}
211
+ </ul>
212
+ </>
213
+ )}
214
+ </div>
215
+ ) : null}
216
+ </li>
217
+ )
218
+ })}
219
+ </ul>
220
+ </>
221
+ )}
222
+ {!model.observed && !model.empty ? <p className="dmcp-derived-note">{t('derivedNote')}</p> : null}
223
+ {probeError !== null ? <p className="dmcp-error-text" role="alert">{t('probeFailedAction')}: {probeError}</p> : null}
224
+ <h3 className="dmcp-heading">{t('probes')}</h3>
225
+ {model.probes.length === 0 ? <p className="dmcp-status">{t('probeEmpty')}</p> : (
226
+ <ul className="dmcp-probes">
227
+ {model.probes.map((probe) => {
228
+ const badge = probeBadge(probe.view.status)
229
+ return (
230
+ <li key={probe.view.id} className="dmcp-probe" data-mcp-probe={probe.view.id}>
231
+ <Badge tone={badge.tone} label={probeLabel(badge.badge, t)} />
232
+ <code>{probe.view.serverName}</code>
233
+ <span className="dmcp-probe-time">{formatProbeTime(probe.view)}</span>
234
+ <span className="dmcp-probe-detail">{probe.view.detail ?? t('none')}</span>
235
+ </li>
236
+ )
237
+ })}
238
+ </ul>
239
+ )}
240
+ {model.patchFile !== null ? (
241
+ <p className="dmcp-patch-hint">{t('patchHint')} <code>{model.patchFile}</code></p>
242
+ ) : null}
243
+ </div>
244
+ ) : null}
245
+ </div>
246
+ )
247
+ }
248
+
249
+ /** One tone-colored badge chip. */
250
+ function Badge({ tone, label }: { readonly tone: BadgeTone; readonly label: string }): ReactNode {
251
+ return (
252
+ <span className="dmcp-badge" data-tone={tone} role="img" aria-label={label} title={label}>
253
+ <span className="dmcp-dot" aria-hidden="true" />
254
+ {label}
255
+ </span>
256
+ )
257
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `dsh-mcp-panel`, browser half: mounts the `mcpPanel` Remote contribution,
3
+ * then registers a read-only "MCP" tab into the Plugins settings section
4
+ * (`settings.plugins.tab`, id `mcp`). All data arrives through the
5
+ * `remote.mcpPanel` namespace — the tab issues no other RPC and holds no
6
+ * state of its own beyond expansion and the last loaded snapshot.
7
+ *
8
+ * @module dsh-mcp-panel/client
9
+ */
10
+
11
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
12
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
13
+ // Type-only: pulls the 'settings.plugins.tab' SlotMap declaration into this
14
+ // program so the tab registration typechecks against the real declaration.
15
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
16
+ import { McpPanelTab, type McpPanelTabInjected } from './McpPanelTab.tsx'
17
+ // The harness locale registry accepts only the 'en' | 'zh' UI language codes
18
+ // today (its LocaleDictOf face), so the tab ships those two dictionaries and
19
+ // follows the app's UI language. The `/mcp` command language is a separate
20
+ // plugin config (`outputLanguage`) with its own five-language dictionaries.
21
+ import { en, zh, type McpPanelLocaleKey } from './locales.ts'
22
+ import { MCP_PANEL_REMOTE } from './remote.ts'
23
+ import { installPanelStyles } from './styles.ts'
24
+ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
25
+ import type { McpPanelSnapshot } from '../wire.ts'
26
+
27
+ export type { McpPanelTabInjected, McpPanelTabProps } from './McpPanelTab.tsx'
28
+ export type { McpPanelLocaleKey } from './locales.ts'
29
+ export { presentMcpPanel, connectionBadge, probeBadge } from './present.ts'
30
+ export type { PresentedMcpPanel, PresentedProbeRow, PresentedServerRow, BadgeTone } from './present.ts'
31
+
32
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
33
+ interface LocaleNamespaceMap {
34
+ /** MCP management tab copy. */
35
+ 'settings.mcpPanel': McpPanelLocaleKey
36
+ }
37
+ }
38
+
39
+ /** Dictionary namespace owned by this plugin. */
40
+ export const NS = 'settings.mcpPanel'
41
+
42
+ /** Plugin name: matches the package name, the graph row id, and the bundle id. */
43
+ export const name = 'dsh-mcp-panel'
44
+
45
+ /** Services the tab reads; `remote.mcpPanel` appears once this plugin mounts its contribution. */
46
+ export const inject = ['slots', 'locale', 'remote']
47
+
48
+ /**
49
+ * Browser plugin body: dictionaries, the scoped stylesheet, the Remote
50
+ * contribution mount, and the settings tab registration.
51
+ *
52
+ * @param ctx - client root context.
53
+ */
54
+ export async function apply(ctx: ClientContext): Promise<void> {
55
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-mcp-panel: dictionaries')
56
+ ctx.effect(() => installPanelStyles(), 'dsh-mcp-panel: stylesheet')
57
+
58
+ // $mount registers the 'remote.mcpPanel' namespace service and owns its
59
+ // removal for this fiber's lifetime.
60
+ await ctx.remote.$mount(MCP_PANEL_REMOTE)
61
+
62
+ ctx.inject(['remote.mcpPanel'], (scope) => {
63
+ const t = scope.locale.bind(NS)
64
+ const status: McpPanelTabInjected['status'] = async () => {
65
+ const result: RemoteResult<McpPanelSnapshot> = await scope.remote.mcpPanel.status()
66
+ if (!result.ok) {
67
+ throw new Error(`mcpPanel.status failed: ${result.error.code}: ${result.error.message}`)
68
+ }
69
+ return result.value
70
+ }
71
+ const probe: McpPanelTabInjected['probe'] = async (serverName) => {
72
+ const result = await scope.remote.mcpPanel.probe(serverName)
73
+ if (!result.ok) {
74
+ throw new Error(`mcpPanel.probe failed: ${result.error.code}: ${result.error.message}`)
75
+ }
76
+ return result.value
77
+ }
78
+ scope.slots.inject('settings.plugins.tab', () => scope.slots.register({
79
+ name: 'settings.plugins.tab',
80
+ id: 'mcp',
81
+ order: 30,
82
+ label: () => t('tab'),
83
+ locale: NS,
84
+ inject: (): McpPanelTabInjected => ({ status, probe }),
85
+ }, McpPanelTab))
86
+ })
87
+ }