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.
- package/LICENSE +201 -0
- package/README.es.md +129 -0
- package/README.hi.md +129 -0
- package/README.md +129 -0
- package/README.pt.md +129 -0
- package/README.zh.md +129 -0
- package/cordis.patch.yml +23 -0
- package/lib/client.js +5045 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1079 -0
- package/lib/typert.host.js +4261 -0
- package/lib/types/aggregate.d.ts +92 -0
- package/lib/types/aggregate.d.ts.map +1 -0
- package/lib/types/client/McpPanelTab.d.ts +16 -0
- package/lib/types/client/McpPanelTab.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +35 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +90 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/present.d.ts +79 -0
- package/lib/types/client/present.d.ts.map +1 -0
- package/lib/types/client/remote.d.ts +119 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/styles.d.ts +12 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/command.d.ts +122 -0
- package/lib/types/command.d.ts.map +1 -0
- package/lib/types/config.d.ts +63 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/grouping.d.ts +49 -0
- package/lib/types/grouping.d.ts.map +1 -0
- package/lib/types/index.d.ts +41 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/probe.d.ts +67 -0
- package/lib/types/probe.d.ts.map +1 -0
- package/lib/types/sanitize.d.ts +42 -0
- package/lib/types/sanitize.d.ts.map +1 -0
- package/lib/types/service.d.ts +107 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +110 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +67 -0
- package/lib/types/upstream.d.ts.map +1 -0
- package/lib/types/wire.d.ts +342 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/package.json +111 -0
- package/src/aggregate.ts +248 -0
- package/src/client/McpPanelTab.tsx +257 -0
- package/src/client/index.ts +87 -0
- package/src/client/locales.ts +92 -0
- package/src/client/present.ts +127 -0
- package/src/client/remote.ts +35 -0
- package/src/client/styles.ts +235 -0
- package/src/command.ts +387 -0
- package/src/config.ts +110 -0
- package/src/grouping.ts +109 -0
- package/src/index.ts +86 -0
- package/src/probe.ts +198 -0
- package/src/sanitize.ts +115 -0
- package/src/service.ts +279 -0
- package/src/typert.host.ts +25 -0
- package/src/upstream.ts +72 -0
- package/src/wire.ts +221 -0
package/src/aggregate.ts
ADDED
|
@@ -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
|
+
}
|