dsh-mcp-panel 0.2.0 → 0.3.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/src/service.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  import { groupMcpTools } from './grouping.ts'
34
34
  import { probeEndpoint, probeJob, PROBE_KIND } from './probe.ts'
35
35
  import { sanitizeText } from './sanitize.ts'
36
- import type { McpServerStatus } from './upstream.ts'
36
+ import type { McpServerStatus, McpStatusPhase } from './upstream.ts'
37
37
  import type { McpFiberPhase, McpPanelSnapshot, McpProbeView, ProbeStarted } from './wire.ts'
38
38
 
39
39
  declare module '@deepseek-ai/cordis' {
@@ -62,6 +62,18 @@ const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
62
62
  /** Label prefix written by the `mcp_probe` tool and the panel probe action. */
63
63
  const PROBE_LABEL_PREFIX = 'mcp_probe '
64
64
 
65
+ /**
66
+ * Supervisor phases this panel understands. Upstream payloads are
67
+ * unvalidated runtime data (any plugin can emit `mcp/status`): a payload
68
+ * whose required fields do not match the wire codec would otherwise be
69
+ * stored verbatim and later REJECT the whole `mcpPanel/status` snapshot on
70
+ * the strict Typert codec, taking down the panel over one bad event.
71
+ */
72
+ const KNOWN_PHASES: ReadonlySet<string> = new Set(['connecting', 'connected', 'waiting', 'exhausted', 'disposed'])
73
+
74
+ /** Background-job statuses the panel displays; anything else reads `unknown`. */
75
+ const KNOWN_JOB_STATUSES: ReadonlySet<string> = new Set(['running', 'stopping', 'completed', 'killed', 'failed'])
76
+
65
77
  /** Service-level runtime settings; the plugin passes its resolved config in. */
66
78
  export interface McpPanelServiceConfig {
67
79
  /** Per-probe timeout in milliseconds. */
@@ -93,6 +105,12 @@ export class McpPanelService extends TypertRemoteService {
93
105
  private readonly statuses = new Map<string, McpServerStatus>()
94
106
  /** Cumulative reconnect attempts observed per server namespace. */
95
107
  private readonly reconnects = new Map<string, number>()
108
+ /**
109
+ * Highest `attempt` already counted per server, so re-observing the same
110
+ * payload (HMR remount, event + query seed of one transition) never double
111
+ * counts a reconnect.
112
+ */
113
+ private readonly countedAttempts = new Map<string, number>()
96
114
  /** Epoch ms of the latest upstream event receipt per server namespace. */
97
115
  private readonly observedAt = new Map<string, number>()
98
116
  /** Latest passive-probe reachability per server namespace. */
@@ -115,17 +133,45 @@ export class McpPanelService extends TypertRemoteService {
115
133
  }
116
134
 
117
135
  /**
118
- * Record one upstream `mcp/status` payload (event or query-seed). A
119
- * `connecting` payload with a positive attempt counts one reconnect.
136
+ * Record one upstream `mcp/status` payload (event or query-seed). Payloads
137
+ * are validated before storage — they are unvalidated runtime data and a
138
+ * malformed one must never poison the strict wire codec downstream. A
139
+ * `connecting` payload with a strictly increasing attempt counts one
140
+ * reconnect; re-observing the same payload never double counts.
120
141
  *
121
142
  * @param payload - post-transition status facts.
122
143
  */
123
144
  observe(payload: McpServerStatus): void {
124
- if (typeof payload.serverName !== 'string' || payload.serverName === '') return
125
- this.statuses.set(payload.serverName, payload)
126
- this.observedAt.set(payload.serverName, Date.now())
127
- if (payload.phase === 'connecting' && payload.attempt > 0) {
128
- this.reconnects.set(payload.serverName, (this.reconnects.get(payload.serverName) ?? 0) + 1)
145
+ if (typeof payload !== 'object' || payload === null) return
146
+ const { serverName, phase, attempt, maxAttempts, toolCount } = payload
147
+ if (typeof serverName !== 'string' || serverName === '') return
148
+ if (typeof phase !== 'string' || !KNOWN_PHASES.has(phase)) return
149
+ if (!Number.isFinite(attempt) || !Number.isFinite(maxAttempts) || !Number.isFinite(toolCount)) return
150
+ // Normalize into the exact shape the wire codec accepts; optional fields
151
+ // drop when they are not codec-shaped instead of rejecting the snapshot.
152
+ const clean: McpServerStatus = {
153
+ serverName,
154
+ phase: phase as McpStatusPhase,
155
+ attempt: Math.floor(attempt),
156
+ maxAttempts: Math.floor(maxAttempts),
157
+ toolCount: Math.floor(toolCount),
158
+ ...(Number.isFinite(payload.delayMs) ? { delayMs: Math.floor(payload.delayMs as number) } : {}),
159
+ ...(typeof payload.error === 'string' ? { error: payload.error } : {}),
160
+ ...(Number.isFinite(payload.connectedAt) ? { connectedAt: Math.floor(payload.connectedAt as number) } : {}),
161
+ }
162
+ this.statuses.set(serverName, clean)
163
+ this.observedAt.set(serverName, Date.now())
164
+ if (clean.phase === 'connecting' && clean.attempt > 0) {
165
+ const counted = this.countedAttempts.get(serverName) ?? 0
166
+ if (clean.attempt > counted) {
167
+ this.countedAttempts.set(serverName, clean.attempt)
168
+ this.reconnects.set(serverName, (this.reconnects.get(serverName) ?? 0) + 1)
169
+ }
170
+ } else if (clean.phase === 'connected' || clean.phase === 'disposed') {
171
+ // A recovery or teardown resets the counter so the next outage counts
172
+ // from attempt 1 again. `waiting`/`exhausted` keep it: those phases
173
+ // carry the ongoing outage's attempt and must not re-arm counting.
174
+ this.countedAttempts.set(serverName, 0)
129
175
  }
130
176
  }
131
177
 
@@ -230,7 +276,11 @@ export class McpPanelService extends TypertRemoteService {
230
276
  for (const entry of this.ctx.loader.entries()) {
231
277
  if (entry.options.name !== MCP_CLIENT_MODULE) continue
232
278
  const config = entry.options.config
233
- if (serverNameOf(config, `entry:${entry.id}`) !== serverName) continue
279
+ // `entry.options.id` — not `entry.id` — keeps the namespace fallback
280
+ // identical to the snapshot's (`entry:<options.id>`); `entry.id` carries
281
+ // enclosing group prefixes (e.g. `include:`), which would silently break
282
+ // probe targeting for rows nested in groups.
283
+ if (serverNameOf(config, `entry:${entry.options.id}`) !== serverName) continue
234
284
  if (typeof config !== 'object' || config === null || Array.isArray(config)) return undefined
235
285
  const row = config as Record<string, unknown>
236
286
  if (row['transport'] !== 'streamable-http') return undefined
@@ -258,7 +308,9 @@ export class McpPanelService extends TypertRemoteService {
258
308
  .map(job => ({
259
309
  id: job.id,
260
310
  serverName: job.label.startsWith(PROBE_LABEL_PREFIX) ? job.label.slice(PROBE_LABEL_PREFIX.length) : job.label,
261
- status: job.status,
311
+ // Job registries may grow lifecycle states; anything outside the
312
+ // panel's vocabulary reads `unknown` instead of failing the codec.
313
+ status: KNOWN_JOB_STATUSES.has(job.status) ? job.status as McpProbeView['status'] : 'unknown',
262
314
  startedAt: job.startedAt,
263
315
  finishedAt: job.finishedAt ?? null,
264
316
  detail: job.detail === undefined ? null : sanitizeText(job.detail),
package/src/wire.ts CHANGED
@@ -82,8 +82,8 @@ export interface McpProbeView {
82
82
  id: string
83
83
  /** Server the probe targeted. */
84
84
  serverName: string
85
- /** Job lifecycle state. */
86
- status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
85
+ /** Job lifecycle state (`unknown` for registry states outside this panel's vocabulary). */
86
+ status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' | 'unknown'
87
87
  /** Epoch ms when the probe started. */
88
88
  startedAt: number
89
89
  /** Epoch ms when the probe settled; `null` while running. */
@@ -139,7 +139,7 @@ export const MCP_PANEL_SNAPSHOT_SCHEMA = z.object({
139
139
  probes: z.array(z.object({
140
140
  id: z.string(),
141
141
  serverName: z.string(),
142
- status: z.union([z.literal('running'), z.literal('stopping'), z.literal('completed'), z.literal('killed'), z.literal('failed')]),
142
+ status: z.union([z.literal('running'), z.literal('stopping'), z.literal('completed'), z.literal('killed'), z.literal('failed'), z.literal('unknown')]),
143
143
  startedAt: z.number().int(),
144
144
  finishedAt: z.number().int().nullable(),
145
145
  detail: z.string().nullable(),