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/lib/index.js
ADDED
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
6
|
+
//#region src/config.ts
|
|
7
|
+
/**
|
|
8
|
+
* Plugin configuration and its explicit resolve step. `resolveConfig` re-judges
|
|
9
|
+
* every default and bound so programmatic construction that bypasses
|
|
10
|
+
* Schemastery normalization still fails loud instead of running with hidden
|
|
11
|
+
* defaults (the explicit-resolve contract).
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-mcp-panel/config
|
|
14
|
+
*/
|
|
15
|
+
/** Default per-probe timeout in milliseconds. */
|
|
16
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 1e4;
|
|
17
|
+
/** Ceiling for a single probe timeout: a probe is a one-shot HTTP call. */
|
|
18
|
+
const MAX_PROBE_TIMEOUT_MS = 3e5;
|
|
19
|
+
/** Ceiling on the suggested panel refresh interval (1 hour). */
|
|
20
|
+
const MAX_REFRESH_INTERVAL_MS = 36e5;
|
|
21
|
+
/** Schemastery schema for loader-validated configuration. */
|
|
22
|
+
const Config = z.object({
|
|
23
|
+
probeEnabled: z.boolean().default(true),
|
|
24
|
+
probeTimeoutMs: z.number().min(1).max(MAX_PROBE_TIMEOUT_MS).default(DEFAULT_PROBE_TIMEOUT_MS),
|
|
25
|
+
maxProbes: z.number().min(1).max(100).default(10),
|
|
26
|
+
refreshIntervalMs: z.number().min(0).max(MAX_REFRESH_INTERVAL_MS).default(0),
|
|
27
|
+
outputLanguage: z.union([
|
|
28
|
+
"en",
|
|
29
|
+
"zh",
|
|
30
|
+
"es",
|
|
31
|
+
"pt",
|
|
32
|
+
"hi"
|
|
33
|
+
]).default("en"),
|
|
34
|
+
passiveProbeEnabled: z.boolean().default(false),
|
|
35
|
+
passiveProbeIntervalMs: z.number().min(1e3).max(MAX_REFRESH_INTERVAL_MS).default(6e4)
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* Resolve raw config to the runtime policy, re-validating defaults and bounds.
|
|
39
|
+
*
|
|
40
|
+
* @param config - raw loader config; `undefined` for a bare row.
|
|
41
|
+
* @returns the frozen resolved config.
|
|
42
|
+
*/
|
|
43
|
+
function resolveConfig(config) {
|
|
44
|
+
const probeEnabled = config?.probeEnabled ?? true;
|
|
45
|
+
if (typeof probeEnabled !== "boolean") throw new TypeError("dsh-mcp-panel: config.probeEnabled must be a boolean");
|
|
46
|
+
const probeTimeoutMs = config?.probeTimeoutMs ?? 1e4;
|
|
47
|
+
if (!Number.isFinite(probeTimeoutMs) || probeTimeoutMs < 1 || probeTimeoutMs > 3e5) throw new Error(`dsh-mcp-panel: config.probeTimeoutMs must be a finite number between 1 and ${MAX_PROBE_TIMEOUT_MS}`);
|
|
48
|
+
const maxProbes = config?.maxProbes ?? 10;
|
|
49
|
+
if (!Number.isInteger(maxProbes) || maxProbes < 1 || maxProbes > 100) throw new Error("dsh-mcp-panel: config.maxProbes must be an integer between 1 and 100");
|
|
50
|
+
const refreshIntervalMs = config?.refreshIntervalMs ?? 0;
|
|
51
|
+
if (!Number.isFinite(refreshIntervalMs) || refreshIntervalMs < 0 || refreshIntervalMs > 36e5) throw new Error(`dsh-mcp-panel: config.refreshIntervalMs must be a finite number between 0 and ${MAX_REFRESH_INTERVAL_MS}`);
|
|
52
|
+
const outputLanguage = config?.outputLanguage ?? "en";
|
|
53
|
+
if (outputLanguage !== "en" && outputLanguage !== "zh" && outputLanguage !== "es" && outputLanguage !== "pt" && outputLanguage !== "hi") throw new Error(`dsh-mcp-panel: config.outputLanguage must be one of "en", "zh", "es", "pt", "hi", got ${JSON.stringify(outputLanguage)}`);
|
|
54
|
+
const passiveProbeEnabled = config?.passiveProbeEnabled ?? false;
|
|
55
|
+
if (typeof passiveProbeEnabled !== "boolean") throw new TypeError("dsh-mcp-panel: config.passiveProbeEnabled must be a boolean");
|
|
56
|
+
const passiveProbeIntervalMs = config?.passiveProbeIntervalMs ?? 6e4;
|
|
57
|
+
if (!Number.isFinite(passiveProbeIntervalMs) || passiveProbeIntervalMs < 1e3 || passiveProbeIntervalMs > 36e5) throw new Error(`dsh-mcp-panel: config.passiveProbeIntervalMs must be a finite number between 1000 and ${MAX_REFRESH_INTERVAL_MS}`);
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
probeEnabled,
|
|
60
|
+
probeTimeoutMs,
|
|
61
|
+
maxProbes,
|
|
62
|
+
refreshIntervalMs,
|
|
63
|
+
outputLanguage,
|
|
64
|
+
passiveProbeEnabled,
|
|
65
|
+
passiveProbeIntervalMs
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/command.ts
|
|
70
|
+
/** Placeholder for fields the panel cannot observe yet. */
|
|
71
|
+
const UNKNOWN = "—";
|
|
72
|
+
/** English output dictionary (default). */
|
|
73
|
+
const EN_MESSAGES = {
|
|
74
|
+
enabled: "enabled",
|
|
75
|
+
disabled: "disabled",
|
|
76
|
+
status: "status",
|
|
77
|
+
reconnects: "reconnects",
|
|
78
|
+
lastError: "last error",
|
|
79
|
+
retryIn: "retry in",
|
|
80
|
+
cordisFiberFailed: "cordis fiber: failed",
|
|
81
|
+
tools: "tools",
|
|
82
|
+
serversHeader: (count) => `MCP servers (${count}):`,
|
|
83
|
+
noServers: "No MCP servers configured (no @deepseek-ai/dsh-mcp-client rows in this profile).",
|
|
84
|
+
noteNoSeam: "Note: connection status/reconnect counts are not observable yet — @deepseek-ai/dsh-mcp-client exposes no status seam.",
|
|
85
|
+
noteProposal: "Upstream proposal: docs/upstream-proposal.md (deepseek-harness). Row facts above are derived from config and the tool registry.",
|
|
86
|
+
noTools: (server) => `No tools registered for "${server}" (server down, sync failed, or reconnect budget exhausted).`,
|
|
87
|
+
toolsHeader: (server, count) => `Tools of "${server}" (${count}, model-visible public names):`,
|
|
88
|
+
noDescription: "(no description)",
|
|
89
|
+
patchIntro: (action, server, entryId, patchFile) => `To ${action} "${server}" (entry ${entryId}), add this line to the profile patch layer${patchFile === null ? "" : ` (${patchFile})`}:`,
|
|
90
|
+
patchNoRuntimeToggle: "@deepseek-ai/dsh-mcp-client has no runtime toggle; the Loader applies the patch on reload.",
|
|
91
|
+
patchReloadPath: "The web surface hot-reloads cordis.patch.yml edits; other surfaces restart. This command never edits your config.",
|
|
92
|
+
usage: "Usage: /mcp | /mcp <server> | /mcp <server> tools | /mcp <server> disable | /mcp <server> enable | /mcp <server> probe",
|
|
93
|
+
probeStarted: (server, jobId) => `Probe started for "${server}" (background job ${jobId}). Read the result in the MCP panel: Settings → Plugins → MCP.`,
|
|
94
|
+
unknownServer: (server, known) => `Unknown MCP server "${server}" (configured: ${known === "" ? "none" : known})`
|
|
95
|
+
};
|
|
96
|
+
/** Every output dictionary indexed by the configured language. */
|
|
97
|
+
const MESSAGES = {
|
|
98
|
+
en: EN_MESSAGES,
|
|
99
|
+
zh: {
|
|
100
|
+
enabled: "已启用",
|
|
101
|
+
disabled: "已停用",
|
|
102
|
+
status: "状态",
|
|
103
|
+
reconnects: "重连",
|
|
104
|
+
lastError: "最近错误",
|
|
105
|
+
retryIn: "重试等待",
|
|
106
|
+
cordisFiberFailed: "cordis fiber: 失败",
|
|
107
|
+
tools: "工具",
|
|
108
|
+
serversHeader: (count) => `MCP 服务器(${count} 个):`,
|
|
109
|
+
noServers: "此 profile 未配置官方 MCP 服务器(@deepseek-ai/dsh-mcp-client 行)。",
|
|
110
|
+
noteNoSeam: "说明:连接状态/重连计数尚不可观测——@deepseek-ai/dsh-mcp-client 未暴露状态 seam。",
|
|
111
|
+
noteProposal: "上游提案:docs/upstream-proposal.md(deepseek-harness)。以上行数据来自配置与工具注册表。",
|
|
112
|
+
noTools: (server) => `"${server}" 未注册任何工具(服务器宕机、同步失败或重连预算耗尽)。`,
|
|
113
|
+
toolsHeader: (server, count) => `"${server}" 的工具(${count} 个,模型可见公开名):`,
|
|
114
|
+
noDescription: "(无描述)",
|
|
115
|
+
patchIntro: (action, server, entryId, patchFile) => {
|
|
116
|
+
return `要${action === "disable" ? "停用" : "启用"} "${server}"(条目 ${entryId}),把下面这行加到 profile patch 层${patchFile === null ? "" : `(${patchFile})`}:`;
|
|
117
|
+
},
|
|
118
|
+
patchNoRuntimeToggle: "@deepseek-ai/dsh-mcp-client 没有运行时开关;Loader 在重载时应用该 patch。",
|
|
119
|
+
patchReloadPath: "web 面板会热重载 cordis.patch.yml 的修改;其他面板重启生效。本命令绝不修改你的配置。",
|
|
120
|
+
usage: "用法:/mcp | /mcp <server> | /mcp <server> tools | /mcp <server> disable | /mcp <server> enable | /mcp <server> probe",
|
|
121
|
+
probeStarted: (server, jobId) => `已对 "${server}" 启动探测(后台任务 ${jobId})。结果仅面板可见:设置 → 插件 → MCP。`,
|
|
122
|
+
unknownServer: (server, known) => `未知 MCP 服务器 "${server}"(已配置:${known === "" ? "无" : known})`
|
|
123
|
+
},
|
|
124
|
+
es: {
|
|
125
|
+
enabled: "habilitado",
|
|
126
|
+
disabled: "deshabilitado",
|
|
127
|
+
status: "estado",
|
|
128
|
+
reconnects: "reconexiones",
|
|
129
|
+
lastError: "último error",
|
|
130
|
+
retryIn: "reintento en",
|
|
131
|
+
cordisFiberFailed: "cordis fiber: falló",
|
|
132
|
+
tools: "herramientas",
|
|
133
|
+
serversHeader: (count) => `Servidores MCP (${count}):`,
|
|
134
|
+
noServers: "No hay servidores MCP configurados (sin filas @deepseek-ai/dsh-mcp-client en este perfil).",
|
|
135
|
+
noteNoSeam: "Nota: el estado de conexión y los conteos de reconexión aún no son observables — @deepseek-ai/dsh-mcp-client no expone una costura de estado.",
|
|
136
|
+
noteProposal: "Propuesta upstream: docs/upstream-proposal.md (deepseek-harness). Los datos de arriba derivan de la configuración y del registro de herramientas.",
|
|
137
|
+
noTools: (server) => `Sin herramientas registradas para "${server}" (servidor caído, sincronización fallida o presupuesto de reconexión agotado).`,
|
|
138
|
+
toolsHeader: (server, count) => `Herramientas de "${server}" (${count}, nombres públicos visibles al modelo):`,
|
|
139
|
+
noDescription: "(sin descripción)",
|
|
140
|
+
patchIntro: (action, server, entryId, patchFile) => {
|
|
141
|
+
return `Para ${action === "disable" ? "deshabilitar" : "habilitar"} "${server}" (entrada ${entryId}), añade esta línea a la capa de parches del perfil${patchFile === null ? "" : ` (${patchFile})`}:`;
|
|
142
|
+
},
|
|
143
|
+
patchNoRuntimeToggle: "@deepseek-ai/dsh-mcp-client no tiene conmutador en tiempo de ejecución; el Loader aplica el parche al recargar.",
|
|
144
|
+
patchReloadPath: "La superficie web recarga en caliente los cambios de cordis.patch.yml; otras superficies se reinician. Este comando nunca edita tu configuración.",
|
|
145
|
+
usage: "Uso: /mcp | /mcp <server> | /mcp <server> tools | /mcp <server> disable | /mcp <server> enable | /mcp <server> probe",
|
|
146
|
+
probeStarted: (server, jobId) => `Sonda iniciada para "${server}" (tarea en segundo plano ${jobId}). Lee el resultado en el panel MCP: Ajustes → Plugins → MCP.`,
|
|
147
|
+
unknownServer: (server, known) => `Servidor MCP desconocido "${server}" (configurados: ${known === "" ? "ninguno" : known})`
|
|
148
|
+
},
|
|
149
|
+
pt: {
|
|
150
|
+
enabled: "habilitado",
|
|
151
|
+
disabled: "desabilitado",
|
|
152
|
+
status: "status",
|
|
153
|
+
reconnects: "reconexões",
|
|
154
|
+
lastError: "último erro",
|
|
155
|
+
retryIn: "nova tentativa em",
|
|
156
|
+
cordisFiberFailed: "cordis fiber: falhou",
|
|
157
|
+
tools: "ferramentas",
|
|
158
|
+
serversHeader: (count) => `Servidores MCP (${count}):`,
|
|
159
|
+
noServers: "Nenhum servidor MCP configurado (nenhuma linha @deepseek-ai/dsh-mcp-client neste perfil).",
|
|
160
|
+
noteNoSeam: "Nota: o status de conexão e as contagens de reconexão ainda não são observáveis — @deepseek-ai/dsh-mcp-client não expõe uma costura de status.",
|
|
161
|
+
noteProposal: "Proposta upstream: docs/upstream-proposal.md (deepseek-harness). Os fatos acima derivam da configuração e do registro de ferramentas.",
|
|
162
|
+
noTools: (server) => `Nenhuma ferramenta registrada para "${server}" (servidor fora do ar, sincronização falhou ou orçamento de reconexão esgotado).`,
|
|
163
|
+
toolsHeader: (server, count) => `Ferramentas de "${server}" (${count}, nomes públicos visíveis ao modelo):`,
|
|
164
|
+
noDescription: "(sem descrição)",
|
|
165
|
+
patchIntro: (action, server, entryId, patchFile) => {
|
|
166
|
+
return `Para ${action === "disable" ? "desabilitar" : "habilitar"} "${server}" (entrada ${entryId}), adicione esta linha à camada de patches do perfil${patchFile === null ? "" : ` (${patchFile})`}:`;
|
|
167
|
+
},
|
|
168
|
+
patchNoRuntimeToggle: "@deepseek-ai/dsh-mcp-client não tem alternância em tempo de execução; o Loader aplica o patch ao recarregar.",
|
|
169
|
+
patchReloadPath: "A superfície web recarrega em quente as edições de cordis.patch.yml; outras superfícies reiniciam. Este comando nunca edita sua configuração.",
|
|
170
|
+
usage: "Uso: /mcp | /mcp <server> | /mcp <server> tools | /mcp <server> disable | /mcp <server> enable | /mcp <server> probe",
|
|
171
|
+
probeStarted: (server, jobId) => `Sonda iniciada para "${server}" (tarefa em segundo plano ${jobId}). Leia o resultado no painel MCP: Configurações → Plugins → MCP.`,
|
|
172
|
+
unknownServer: (server, known) => `Servidor MCP desconhecido "${server}" (configurados: ${known === "" ? "nenhum" : known})`
|
|
173
|
+
},
|
|
174
|
+
hi: {
|
|
175
|
+
enabled: "सक्षम",
|
|
176
|
+
disabled: "अक्षम",
|
|
177
|
+
status: "स्थिति",
|
|
178
|
+
reconnects: "रीकनेक्ट",
|
|
179
|
+
lastError: "अंतिम त्रुटि",
|
|
180
|
+
retryIn: "पुनः प्रयास",
|
|
181
|
+
cordisFiberFailed: "cordis fiber: विफल",
|
|
182
|
+
tools: "टूल",
|
|
183
|
+
serversHeader: (count) => `MCP सर्वर (${count}):`,
|
|
184
|
+
noServers: "कोई MCP सर्वर कॉन्फ़िगर नहीं (इस प्रोफ़ाइल में कोई @deepseek-ai/dsh-mcp-client पंक्ति नहीं)।",
|
|
185
|
+
noteNoSeam: "नोट: कनेक्शन स्थिति/रीकनेक्ट गणना अभी देखने योग्य नहीं — @deepseek-ai/dsh-mcp-client कोई स्थिति सीम उजागर नहीं करता।",
|
|
186
|
+
noteProposal: "अपस्ट्रीम प्रस्ताव: docs/upstream-proposal.md (deepseek-harness)। ऊपर के तथ्य कॉन्फ़िगरेशन और टूल रजिस्ट्री से प्राप्त हैं।",
|
|
187
|
+
noTools: (server) => `"${server}" के लिए कोई टूल पंजीकृत नहीं (सर्वर डाउन, सिंक विफल या रीकनेक्ट बजट समाप्त)।`,
|
|
188
|
+
toolsHeader: (server, count) => `"${server}" के टूल (${count}, मॉडल-दृश्य सार्वजनिक नाम):`,
|
|
189
|
+
noDescription: "(कोई विवरण नहीं)",
|
|
190
|
+
patchIntro: (action, server, entryId, patchFile) => {
|
|
191
|
+
return `"${server}" (एंट्री ${entryId}) को ${action === "disable" ? "अक्षम" : "सक्षम"} करने के लिए प्रोफ़ाइल पैच परत${patchFile === null ? "" : ` (${patchFile})`} में यह पंक्ति जोड़ें:`;
|
|
192
|
+
},
|
|
193
|
+
patchNoRuntimeToggle: "@deepseek-ai/dsh-mcp-client के पास रनटाइम टॉगल नहीं है; Loader रीलोड पर पैच लागू करता है।",
|
|
194
|
+
patchReloadPath: "वेब सतह cordis.patch.yml के बदलाव हॉट-रीलोड करती है; अन्य सतहें रीस्टार्ट करें। यह कमांड आपका कॉन्फ़िगरेशन कभी नहीं बदलती।",
|
|
195
|
+
usage: "उपयोग: /mcp | /mcp <server> | /mcp <server> tools | /mcp <server> disable | /mcp <server> enable | /mcp <server> probe",
|
|
196
|
+
probeStarted: (server, jobId) => `"${server}" के लिए प्रोब शुरू (बैकग्राउंड जॉब ${jobId})। परिणाम MCP पैनल में पढ़ें: सेटिंग्स → प्लगइन्स → MCP।`,
|
|
197
|
+
unknownServer: (server, known) => `अज्ञात MCP सर्वर "${server}" (कॉन्फ़िगर: ${known === "" ? "कोई नहीं" : known})`
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
/** One-line count of the reconnection attempts observed this process. */
|
|
201
|
+
function reconnectText(view) {
|
|
202
|
+
return view.reconnectCount < 0 ? UNKNOWN : String(view.reconnectCount);
|
|
203
|
+
}
|
|
204
|
+
/** One-line recent-error summary. */
|
|
205
|
+
function lastErrorText(view) {
|
|
206
|
+
return view.lastError ?? UNKNOWN;
|
|
207
|
+
}
|
|
208
|
+
/** Human status phrase with its provenance. */
|
|
209
|
+
function statusText(view) {
|
|
210
|
+
return `${view.phase === "unknown" ? "unknown" : view.phase} (source: ${view.statusSource})`;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Render one server row: `name [entryId] transport target | N tools | …`.
|
|
214
|
+
*
|
|
215
|
+
* @param view - the assembled server view.
|
|
216
|
+
* @param messages - the output dictionary.
|
|
217
|
+
* @returns the single display line.
|
|
218
|
+
*/
|
|
219
|
+
function renderServer(view, messages = EN_MESSAGES) {
|
|
220
|
+
const state = view.enabled ? messages.enabled : messages.disabled;
|
|
221
|
+
const details = [
|
|
222
|
+
`${messages.status}: ${statusText(view)}`,
|
|
223
|
+
`${messages.reconnects}: ${reconnectText(view)}`,
|
|
224
|
+
`${messages.lastError}: ${lastErrorText(view)}`
|
|
225
|
+
];
|
|
226
|
+
if (view.fiberPhase === "failed") details.push(messages.cordisFiberFailed);
|
|
227
|
+
if (view.delayMs !== null) details.push(`${messages.retryIn} ${view.delayMs}ms`);
|
|
228
|
+
return `- ${view.serverName} [${view.entryId}] ${view.transport} ${view.target} | ${view.toolCount} ${messages.tools} | ${state} | ${details.join(" | ")}`;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Render the no-argument listing.
|
|
232
|
+
*
|
|
233
|
+
* @param snapshot - the current snapshot.
|
|
234
|
+
* @param messages - the output dictionary.
|
|
235
|
+
* @returns the full listing text.
|
|
236
|
+
*/
|
|
237
|
+
function renderList(snapshot, messages = EN_MESSAGES) {
|
|
238
|
+
if (snapshot.servers.length === 0) return messages.noServers;
|
|
239
|
+
const lines = [messages.serversHeader(snapshot.servers.length)];
|
|
240
|
+
for (const view of snapshot.servers) lines.push(renderServer(view, messages));
|
|
241
|
+
if (!snapshot.observed) lines.push(messages.noteNoSeam, messages.noteProposal);
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Render the tool list for one server.
|
|
246
|
+
*
|
|
247
|
+
* @param view - the assembled server view.
|
|
248
|
+
* @param messages - the output dictionary.
|
|
249
|
+
* @returns the tool listing text.
|
|
250
|
+
*/
|
|
251
|
+
function renderTools(view, messages = EN_MESSAGES) {
|
|
252
|
+
if (view.tools.length === 0) return messages.noTools(view.serverName);
|
|
253
|
+
const lines = [messages.toolsHeader(view.serverName, view.tools.length)];
|
|
254
|
+
for (const tool of view.tools) {
|
|
255
|
+
const description = tool.description.trim() === "" ? messages.noDescription : tool.description;
|
|
256
|
+
lines.push(`- ${tool.name} — ${description}`);
|
|
257
|
+
}
|
|
258
|
+
return lines.join("\n");
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Render the controlled enable/disable patch suggestion. Reads only; the user
|
|
262
|
+
* applies the line themselves. The web surface hot-reloads the profile patch
|
|
263
|
+
* layer; other surfaces apply it on restart.
|
|
264
|
+
*
|
|
265
|
+
* @param view - the assembled server view.
|
|
266
|
+
* @param action - which direction the suggestion flips.
|
|
267
|
+
* @param patchFile - absolute profile patch-layer path, or null when unknown.
|
|
268
|
+
* @param messages - the output dictionary.
|
|
269
|
+
* @returns the suggestion text.
|
|
270
|
+
*/
|
|
271
|
+
function renderPatchSuggestion(view, action, patchFile, messages = EN_MESSAGES) {
|
|
272
|
+
const disabled = action === "disable";
|
|
273
|
+
const patch = `- set: { id: ${view.entryId}, name: '@deepseek-ai/dsh-mcp-client', disabled: ${disabled} }`;
|
|
274
|
+
return [
|
|
275
|
+
messages.patchIntro(action, view.serverName, view.entryId, patchFile),
|
|
276
|
+
"",
|
|
277
|
+
patch,
|
|
278
|
+
"",
|
|
279
|
+
messages.patchNoRuntimeToggle,
|
|
280
|
+
messages.patchReloadPath
|
|
281
|
+
].join("\n");
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Parse the free-form command input.
|
|
285
|
+
*
|
|
286
|
+
* @param rawInput - text after `/mcp`, including leading whitespace.
|
|
287
|
+
* @returns the parsed intent; malformed input becomes `usage`.
|
|
288
|
+
*/
|
|
289
|
+
function parseMcpArgs(rawInput) {
|
|
290
|
+
const tokens = rawInput.trim().split(/\s+/u).filter((token) => token !== "");
|
|
291
|
+
if (tokens.length === 0) return { kind: "list" };
|
|
292
|
+
const server = tokens[0];
|
|
293
|
+
const action = tokens[1];
|
|
294
|
+
if (action === void 0) return {
|
|
295
|
+
kind: "server",
|
|
296
|
+
server: server ?? "",
|
|
297
|
+
action: "detail"
|
|
298
|
+
};
|
|
299
|
+
if (tokens.length !== 2) return { kind: "usage" };
|
|
300
|
+
if (action === "tools" || action === "disable" || action === "enable" || action === "probe") return {
|
|
301
|
+
kind: "server",
|
|
302
|
+
server: server ?? "",
|
|
303
|
+
action
|
|
304
|
+
};
|
|
305
|
+
return { kind: "usage" };
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Build the `/mcp` command definition over one service instance.
|
|
309
|
+
*
|
|
310
|
+
* @param service - the panel service supplying snapshots.
|
|
311
|
+
* @param language - output language for the rendered text.
|
|
312
|
+
* @returns the registration-ready definition.
|
|
313
|
+
*/
|
|
314
|
+
function mcpCommand(service, language = "en") {
|
|
315
|
+
const messages = MESSAGES[language] ?? EN_MESSAGES;
|
|
316
|
+
return {
|
|
317
|
+
name: "mcp",
|
|
318
|
+
description: "Show MCP server status, tools, and enable/disable patch suggestions (read-only)",
|
|
319
|
+
input: { hint: "[server] [tools|disable|enable|probe]" },
|
|
320
|
+
handler: ({ rawInput }) => {
|
|
321
|
+
const parsed = parseMcpArgs(rawInput);
|
|
322
|
+
if (parsed.kind === "usage") return {
|
|
323
|
+
kind: "error",
|
|
324
|
+
text: messages.usage
|
|
325
|
+
};
|
|
326
|
+
const snapshot = service.status();
|
|
327
|
+
if (parsed.kind === "list") return {
|
|
328
|
+
kind: "success",
|
|
329
|
+
text: renderList(snapshot, messages)
|
|
330
|
+
};
|
|
331
|
+
const view = snapshot.servers.find((candidate) => candidate.serverName === parsed.server);
|
|
332
|
+
if (view === void 0) {
|
|
333
|
+
const known = snapshot.servers.map((candidate) => candidate.serverName).join(", ");
|
|
334
|
+
return {
|
|
335
|
+
kind: "error",
|
|
336
|
+
text: messages.unknownServer(parsed.server, known)
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
switch (parsed.action) {
|
|
340
|
+
case "tools": return {
|
|
341
|
+
kind: "success",
|
|
342
|
+
text: renderTools(view, messages)
|
|
343
|
+
};
|
|
344
|
+
case "disable": return {
|
|
345
|
+
kind: "success",
|
|
346
|
+
text: renderPatchSuggestion(view, "disable", snapshot.patchFile, messages)
|
|
347
|
+
};
|
|
348
|
+
case "enable": return {
|
|
349
|
+
kind: "success",
|
|
350
|
+
text: renderPatchSuggestion(view, "enable", snapshot.patchFile, messages)
|
|
351
|
+
};
|
|
352
|
+
case "probe": try {
|
|
353
|
+
const started = service.probe(parsed.server);
|
|
354
|
+
return {
|
|
355
|
+
kind: "success",
|
|
356
|
+
text: messages.probeStarted(parsed.server, started.jobId)
|
|
357
|
+
};
|
|
358
|
+
} catch (error) {
|
|
359
|
+
return {
|
|
360
|
+
kind: "error",
|
|
361
|
+
text: error instanceof Error ? error.message : String(error)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
default: return {
|
|
365
|
+
kind: "success",
|
|
366
|
+
text: renderServer(view, messages)
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/** Query/field keys whose values are credentials regardless of their name casing. */
|
|
373
|
+
const CREDENTIAL_KEY = /^(?:access[_-]?token|api[_-]?key|apikey|auth|authorization|client[_-]?secret|key|password|passwd|pwd|secret|sig|signature|token)$/iu;
|
|
374
|
+
/** Credential keys for the unparseable-URL fallback and embedded-text scans. */
|
|
375
|
+
const CREDENTIAL_KEY_SOURCE = "(?:access[_-]?token|api[_-]?key|apikey|auth(?:orization)?|client[_-]?secret|key|passw(?:or)?d|passwd|pwd|secret|sig(?:nature)?|token)";
|
|
376
|
+
/** Whole userinfo before `@` (unparseable URLs only — parsed URLs redact just the password). */
|
|
377
|
+
const USERINFO = /([a-z][a-z0-9+.-]*:\/\/)([^/@\s]+)@/giu;
|
|
378
|
+
/** `?key=value` / `&key=value` credential pairs inside arbitrary text. */
|
|
379
|
+
const QUERY_CREDENTIAL = new RegExp(`([?&](?:[^=&#\\s]*${CREDENTIAL_KEY_SOURCE}[^=&#\\s]*)=)[^&#\\s]*`, "giu");
|
|
380
|
+
/** `#key=value` credential pairs in URL fragments and arbitrary text. */
|
|
381
|
+
const FRAGMENT_CREDENTIAL = new RegExp(`(#[^=&#\\s]*${CREDENTIAL_KEY_SOURCE}[^=&#\\s]*=)[^&#\\s]*`, "giu");
|
|
382
|
+
/** `Authorization: <value>`-style header lines in arbitrary text (quoted value first). */
|
|
383
|
+
const HEADER_CREDENTIAL_QUOTED = new RegExp(`(\\b${CREDENTIAL_KEY_SOURCE}\\s*[:=]\\s*["'])[^"']*(["'])`, "giu");
|
|
384
|
+
/** `Authorization: <value>`-style header lines with unquoted values. */
|
|
385
|
+
const HEADER_CREDENTIAL_BARE = new RegExp(`(\\b${CREDENTIAL_KEY_SOURCE}\\s*[:=]\\s*)[^\\s,;)\\]}]+`, "giu");
|
|
386
|
+
/** Environment-variable-shaped credentials (`GITHUB_TOKEN=…`) in spawn errors. */
|
|
387
|
+
const ENV_VAR_CREDENTIAL = /\b[A-Za-z0-9_]*(?:TOKEN|API[_-]?KEY|SECRET|PASSWORD|PASSWD)[A-Za-z0-9_]*\s*=\s*[^\s,;)\]}]+/gu;
|
|
388
|
+
/** Bearer tokens, including the `Bearer ` keyword and the token itself. */
|
|
389
|
+
const BEARER = /(bearer)\s+[A-Za-z0-9._~+/=-]+/giu;
|
|
390
|
+
/** Quoted JSON-ish `"token": "value"` pairs in arbitrary text. */
|
|
391
|
+
const QUOTED_CREDENTIAL = new RegExp(`(["'](?:access[_-]?token|api[_-]?key|client[_-]?secret|secret|token)["']\\s*[:=]\\s*["'])[^"']*(["'])`, "giu");
|
|
392
|
+
/** Raw JWT bodies, wherever they appear. */
|
|
393
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/gu;
|
|
394
|
+
/**
|
|
395
|
+
* Redact a URL for display: userinfo password, credential query values, and
|
|
396
|
+
* credential fragment pairs. Query keys are read through `URLSearchParams`,
|
|
397
|
+
* so percent-encoded key names are decoded before matching. Unparseable
|
|
398
|
+
* inputs fall back to pattern redaction (whole userinfo, credential query
|
|
399
|
+
* pairs, credential fragment pairs) instead of throwing.
|
|
400
|
+
*
|
|
401
|
+
* @param url - candidate URL text.
|
|
402
|
+
* @returns display-safe URL text.
|
|
403
|
+
*/
|
|
404
|
+
function sanitizeUrl(url) {
|
|
405
|
+
let parsed;
|
|
406
|
+
try {
|
|
407
|
+
parsed = new URL(url);
|
|
408
|
+
} catch {
|
|
409
|
+
return url.replace(USERINFO, "$1***@").replace(QUERY_CREDENTIAL, `$1***`).replace(FRAGMENT_CREDENTIAL, `$1***`);
|
|
410
|
+
}
|
|
411
|
+
if (parsed.password !== "") parsed.password = "***";
|
|
412
|
+
for (const key of [...parsed.searchParams.keys()]) if (CREDENTIAL_KEY.test(key)) parsed.searchParams.set(key, "***");
|
|
413
|
+
if (parsed.hash !== "") parsed.hash = parsed.hash.replace(FRAGMENT_CREDENTIAL, `$1***`);
|
|
414
|
+
return parsed.toString();
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Redact credential-shaped fragments from free text: header lines, bearer
|
|
418
|
+
* tokens, raw JWTs, embedded query pairs, and quoted token values.
|
|
419
|
+
*
|
|
420
|
+
* @param text - candidate display text.
|
|
421
|
+
* @returns display-safe text.
|
|
422
|
+
*/
|
|
423
|
+
function sanitizeText(text) {
|
|
424
|
+
return text.replace(BEARER, `$1 ***`).replace(HEADER_CREDENTIAL_QUOTED, `$1***$2`).replace(HEADER_CREDENTIAL_BARE, `$1***`).replace(ENV_VAR_CREDENTIAL, (value) => {
|
|
425
|
+
const equals = value.indexOf("=");
|
|
426
|
+
return equals < 0 ? value : `${value.slice(0, equals)}=***`;
|
|
427
|
+
}).replace(QUOTED_CREDENTIAL, `$1***$2`).replace(QUERY_CREDENTIAL, `$1***`).replace(FRAGMENT_CREDENTIAL, `$1***`).replace(JWT, "***");
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Stringify an arbitrary thrown value safely and redact it for display.
|
|
431
|
+
* Never throws: unrenderable values degrade to a fixed marker.
|
|
432
|
+
*
|
|
433
|
+
* @param error - thrown value from a connection attempt, probe, or sync.
|
|
434
|
+
* @returns display-safe error text.
|
|
435
|
+
*/
|
|
436
|
+
function sanitizeError(error) {
|
|
437
|
+
let text;
|
|
438
|
+
try {
|
|
439
|
+
text = typeof error === "string" ? error : String(error);
|
|
440
|
+
} catch {
|
|
441
|
+
text = "<unrenderable error>";
|
|
442
|
+
}
|
|
443
|
+
return sanitizeText(text);
|
|
444
|
+
}
|
|
445
|
+
//#endregion
|
|
446
|
+
//#region src/probe.ts
|
|
447
|
+
/** Producer kind; also the job-id prefix. */
|
|
448
|
+
const PROBE_KIND = "mcp-probe";
|
|
449
|
+
/** JSON-RPC MCP initialize request used by the probe (protocol constant). */
|
|
450
|
+
const INITIALIZE_PROTOCOL_VERSION = "2024-11-05";
|
|
451
|
+
/** MCP clientInfo facts; protocol constants, not configuration. Exported so the
|
|
452
|
+
* version-consistency tripwire (`tests/version.spec.ts`) can assert the
|
|
453
|
+
* advertised version tracks the package version. */
|
|
454
|
+
const PROBE_CLIENT_INFO = {
|
|
455
|
+
name: "dsh-mcp-panel",
|
|
456
|
+
version: "0.2.0"
|
|
457
|
+
};
|
|
458
|
+
/** Display cap for server-reported name/version fields in probe details. */
|
|
459
|
+
const DISPLAY_LIMIT = 80;
|
|
460
|
+
/** Bound one display string so hostile server metadata cannot blow up layouts. */
|
|
461
|
+
function boundedDisplay(value) {
|
|
462
|
+
return value.length <= DISPLAY_LIMIT ? value : `${value.slice(0, 79)}…`;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* POST one MCP `initialize` request and describe the outcome in one sanitized
|
|
466
|
+
* line. Never sends or echoes credentials: the configured headers are used
|
|
467
|
+
* for the request itself (exactly as the bridge would) and never rendered.
|
|
468
|
+
*
|
|
469
|
+
* @param url - endpoint URL (already parsed by the caller).
|
|
470
|
+
* @param headers - the configured request headers; used, never displayed.
|
|
471
|
+
* @param timeoutMs - probe deadline.
|
|
472
|
+
* @param signal - caller-owned abort (job kill or timeout).
|
|
473
|
+
* @returns the settled probe outcome.
|
|
474
|
+
*/
|
|
475
|
+
async function probeEndpoint(url, headers, timeoutMs, signal) {
|
|
476
|
+
const started = Date.now();
|
|
477
|
+
try {
|
|
478
|
+
const response = await fetch(url, {
|
|
479
|
+
method: "POST",
|
|
480
|
+
headers: {
|
|
481
|
+
"content-type": "application/json",
|
|
482
|
+
accept: "application/json, text/event-stream",
|
|
483
|
+
...headers
|
|
484
|
+
},
|
|
485
|
+
body: JSON.stringify({
|
|
486
|
+
jsonrpc: "2.0",
|
|
487
|
+
id: 1,
|
|
488
|
+
method: "initialize",
|
|
489
|
+
params: {
|
|
490
|
+
protocolVersion: INITIALIZE_PROTOCOL_VERSION,
|
|
491
|
+
capabilities: {},
|
|
492
|
+
clientInfo: PROBE_CLIENT_INFO
|
|
493
|
+
}
|
|
494
|
+
}),
|
|
495
|
+
signal
|
|
496
|
+
});
|
|
497
|
+
const ms = Date.now() - started;
|
|
498
|
+
if (!response.ok) return {
|
|
499
|
+
status: "failed",
|
|
500
|
+
detail: `HTTP ${response.status} ${response.statusText} (${ms}ms)`
|
|
501
|
+
};
|
|
502
|
+
let serverInfo = {};
|
|
503
|
+
try {
|
|
504
|
+
serverInfo = (await response.json())?.result?.serverInfo ?? {};
|
|
505
|
+
} catch {}
|
|
506
|
+
const name = typeof serverInfo.name === "string" && serverInfo.name !== "" ? boundedDisplay(sanitizeText(serverInfo.name)) : "unnamed";
|
|
507
|
+
const version = typeof serverInfo.version === "string" && serverInfo.version !== "" ? boundedDisplay(sanitizeText(serverInfo.version)) : "unknown version";
|
|
508
|
+
return {
|
|
509
|
+
status: "completed",
|
|
510
|
+
detail: `HTTP ${response.status}, MCP initialize ok (server ${name} ${version}) in ${ms}ms`
|
|
511
|
+
};
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (signal.aborted) return {
|
|
514
|
+
status: "failed",
|
|
515
|
+
detail: `timeout after ${timeoutMs}ms or cancelled`
|
|
516
|
+
};
|
|
517
|
+
return {
|
|
518
|
+
status: "failed",
|
|
519
|
+
detail: sanitizeError(error)
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Create the background-job hooks for one probe: cancel aborts the fetch, the
|
|
525
|
+
* outcome settles `done` with sanitized detail only.
|
|
526
|
+
*
|
|
527
|
+
* @param url - endpoint URL.
|
|
528
|
+
* @param headers - configured headers (used, never rendered).
|
|
529
|
+
* @param timeoutMs - probe deadline.
|
|
530
|
+
* @returns the registry hooks.
|
|
531
|
+
*/
|
|
532
|
+
function probeJob(url, headers, timeoutMs) {
|
|
533
|
+
const controller = new AbortController();
|
|
534
|
+
const timer = setTimeout(() => {
|
|
535
|
+
controller.abort();
|
|
536
|
+
}, timeoutMs);
|
|
537
|
+
timer.unref?.();
|
|
538
|
+
return {
|
|
539
|
+
cancel: () => {
|
|
540
|
+
controller.abort();
|
|
541
|
+
},
|
|
542
|
+
done: probeEndpoint(url, headers, timeoutMs, controller.signal).then((outcome) => ({ ...outcome })).finally(() => {
|
|
543
|
+
clearTimeout(timer);
|
|
544
|
+
})
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/** Resolve one configured server's raw endpoint for probing. */
|
|
548
|
+
function probeTarget(service, server) {
|
|
549
|
+
return service.rawEndpoint(server);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Build the `mcp_probe` tool definition.
|
|
553
|
+
*
|
|
554
|
+
* @param service - panel service (server lookup + snapshot for panel display).
|
|
555
|
+
* @param jobs - background-job registry the probe runs on.
|
|
556
|
+
* @param timeoutMs - per-probe deadline.
|
|
557
|
+
* @returns the registration-ready definition.
|
|
558
|
+
*/
|
|
559
|
+
function mcpProbeTool(service, jobs, timeoutMs) {
|
|
560
|
+
return defineTool({
|
|
561
|
+
name: "mcp_probe",
|
|
562
|
+
description: "Run a one-shot connectivity probe of a configured streamable-http MCP server as a background job. Results appear in the MCP settings panel only — they are not injected into model context.",
|
|
563
|
+
parameters: { server: {
|
|
564
|
+
type: "string",
|
|
565
|
+
required: true,
|
|
566
|
+
description: "serverName of a configured streamable-http MCP server (see /mcp for the list)."
|
|
567
|
+
} },
|
|
568
|
+
output: {
|
|
569
|
+
schema: {
|
|
570
|
+
type: "object",
|
|
571
|
+
additionalProperties: false,
|
|
572
|
+
properties: {
|
|
573
|
+
jobId: {
|
|
574
|
+
type: "string",
|
|
575
|
+
required: true
|
|
576
|
+
},
|
|
577
|
+
note: {
|
|
578
|
+
type: "string",
|
|
579
|
+
required: true
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
render: (_args, value) => [{
|
|
584
|
+
type: "text",
|
|
585
|
+
text: `Probe started (background job ${value.jobId}). Read the result in the MCP panel: Settings → Plugins → MCP.`
|
|
586
|
+
}]
|
|
587
|
+
},
|
|
588
|
+
async execute(args) {
|
|
589
|
+
const target = probeTarget(service, args.server);
|
|
590
|
+
if (target === void 0) throw new Error(`mcp_probe: "${args.server}" is not a configured streamable-http MCP server (see /mcp). stdio servers have no HTTP endpoint to probe.`);
|
|
591
|
+
return {
|
|
592
|
+
jobId: jobs.start({
|
|
593
|
+
kind: PROBE_KIND,
|
|
594
|
+
label: `mcp_probe ${args.server}`,
|
|
595
|
+
run: () => probeJob(target.url, target.headers, timeoutMs)
|
|
596
|
+
}),
|
|
597
|
+
note: "Probe results are panel-only: Settings → Plugins → MCP."
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
/** Marker shown for a `!!js` config value, which the panel never evaluates. */
|
|
603
|
+
const JS_EXPRESSION_MARKER = "<expression>";
|
|
604
|
+
/** Read a plain JSON value from raw config; `!!js` nodes and wrong types become the fallback. */
|
|
605
|
+
function plainField(config, key) {
|
|
606
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) return void 0;
|
|
607
|
+
const value = config[key];
|
|
608
|
+
if (value === void 0 || value === null) return void 0;
|
|
609
|
+
if (typeof value === "object" && "__jsExpr" in value) return void 0;
|
|
610
|
+
return value;
|
|
611
|
+
}
|
|
612
|
+
/** Read a string config field; absent, non-string, or `!!js` becomes the fallback. */
|
|
613
|
+
function stringField(config, key, fallback) {
|
|
614
|
+
const value = plainField(config, key);
|
|
615
|
+
return typeof value === "string" ? value : fallback;
|
|
616
|
+
}
|
|
617
|
+
/** Render one argument for the display command line. */
|
|
618
|
+
function renderArg(arg) {
|
|
619
|
+
if (typeof arg === "string") return /^[\w./:@%+=,_-]+$/u.test(arg) ? arg : JSON.stringify(arg);
|
|
620
|
+
if (typeof arg === "object" && arg !== null && "__jsExpr" in arg) return JS_EXPRESSION_MARKER;
|
|
621
|
+
try {
|
|
622
|
+
return JSON.stringify(arg);
|
|
623
|
+
} catch {
|
|
624
|
+
return JS_EXPRESSION_MARKER;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** Transport and display target derived from one raw mcp-client config. */
|
|
628
|
+
function deriveTarget(config) {
|
|
629
|
+
const transport = stringField(config, "transport", "");
|
|
630
|
+
if (transport === "stdio") {
|
|
631
|
+
const command = stringField(config, "command", "");
|
|
632
|
+
if (command === "") return {
|
|
633
|
+
transport: "unknown",
|
|
634
|
+
target: "(unconfigured)"
|
|
635
|
+
};
|
|
636
|
+
const argsValue = plainField(config, "args");
|
|
637
|
+
const args = Array.isArray(argsValue) ? argsValue.map(renderArg) : [];
|
|
638
|
+
return {
|
|
639
|
+
transport: "stdio",
|
|
640
|
+
target: args.length === 0 ? command : `${command} ${args.join(" ")}`
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
if (transport === "streamable-http") {
|
|
644
|
+
const url = stringField(config, "url", "");
|
|
645
|
+
if (url === "") return {
|
|
646
|
+
transport: "unknown",
|
|
647
|
+
target: "(unconfigured)"
|
|
648
|
+
};
|
|
649
|
+
return {
|
|
650
|
+
transport: "streamable-http",
|
|
651
|
+
target: sanitizeUrl(url)
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
transport: "unknown",
|
|
656
|
+
target: "(unconfigured)"
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
/** Read the server namespace from raw config; absent becomes a stable fallback. */
|
|
660
|
+
function serverNameOf(config, fallback) {
|
|
661
|
+
const name = stringField(config, "serverName", "");
|
|
662
|
+
return name === "" ? fallback : name;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Derive the config-declared policy facts in one display line. Reads only
|
|
666
|
+
* boolean and number fields (never strings from raw config), so nothing
|
|
667
|
+
* user-supplied or secret can leak; `null` = nothing noteworthy configured.
|
|
668
|
+
*/
|
|
669
|
+
function configuredNote(config) {
|
|
670
|
+
const parts = [];
|
|
671
|
+
const reconnectValue = plainField(config, "reconnect");
|
|
672
|
+
if (typeof reconnectValue === "object" && reconnectValue !== null && !Array.isArray(reconnectValue)) {
|
|
673
|
+
const enabled = plainField(reconnectValue, "enabled");
|
|
674
|
+
const maxAttempts = plainField(reconnectValue, "maxAttempts");
|
|
675
|
+
if (typeof enabled === "boolean" && !enabled) parts.push("reconnect off");
|
|
676
|
+
else if (typeof maxAttempts === "number" && Number.isFinite(maxAttempts)) parts.push(`reconnect max ${maxAttempts}`);
|
|
677
|
+
}
|
|
678
|
+
if (plainField(config, "failOnStartupError") === true) parts.push("fail on startup error");
|
|
679
|
+
const toolTimeout = plainField(config, "toolCallTimeoutMs");
|
|
680
|
+
if (typeof toolTimeout === "number" && Number.isFinite(toolTimeout)) parts.push(`tool timeout ${Math.round(toolTimeout / 1e3)}s`);
|
|
681
|
+
return parts.length === 0 ? null : parts.join("; ");
|
|
682
|
+
}
|
|
683
|
+
/** Upstream phase projected onto the wire vocabulary (unknown when unobserved). */
|
|
684
|
+
function connectionPhase(status) {
|
|
685
|
+
if (status === void 0) return "unknown";
|
|
686
|
+
return status.phase;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Assemble one server view from loader, registry, and upstream facts.
|
|
690
|
+
* Missing upstream data degrades to `unknown`/`-1`/`null` — never fabricated.
|
|
691
|
+
*
|
|
692
|
+
* @param row - the mcp-client loader row, or `undefined` for leftover namespaces.
|
|
693
|
+
* @param serverName - the effective namespace.
|
|
694
|
+
* @param group - the tool group for this namespace (possibly empty).
|
|
695
|
+
* @param facts - upstream observations and reconnect totals.
|
|
696
|
+
* @returns the display-ready view.
|
|
697
|
+
*/
|
|
698
|
+
function aggregateServerView(row, serverName, group, facts) {
|
|
699
|
+
const status = facts.statuses.get(serverName);
|
|
700
|
+
const { transport, target } = row === void 0 ? {
|
|
701
|
+
transport: "unknown",
|
|
702
|
+
target: "(unconfigured)"
|
|
703
|
+
} : deriveTarget(row.config);
|
|
704
|
+
const lastError = status?.error === void 0 ? null : sanitizeError(status.error);
|
|
705
|
+
const attempt = status?.attempt ?? -1;
|
|
706
|
+
const maxAttempts = status?.maxAttempts ?? -1;
|
|
707
|
+
const reconnect = facts.reconnects.get(serverName) ?? -1;
|
|
708
|
+
const connectedAt = status?.connectedAt ?? null;
|
|
709
|
+
const delayMs = status?.delayMs ?? null;
|
|
710
|
+
const observedAt = facts.observedAt.get(serverName) ?? null;
|
|
711
|
+
const probe = facts.probeStates.get(serverName);
|
|
712
|
+
return {
|
|
713
|
+
serverName,
|
|
714
|
+
entryId: row?.entryId ?? "",
|
|
715
|
+
transport,
|
|
716
|
+
target,
|
|
717
|
+
enabled: row?.disabled === false,
|
|
718
|
+
fiberPhase: row?.fiberPhase ?? null,
|
|
719
|
+
configuredNote: row === void 0 ? null : configuredNote(row.config),
|
|
720
|
+
toolCount: group?.tools.length ?? 0,
|
|
721
|
+
tools: group?.tools ?? [],
|
|
722
|
+
phase: connectionPhase(status),
|
|
723
|
+
attempt,
|
|
724
|
+
maxAttempts,
|
|
725
|
+
delayMs,
|
|
726
|
+
reconnectCount: reconnect,
|
|
727
|
+
lastError,
|
|
728
|
+
connectedAt,
|
|
729
|
+
observedAt,
|
|
730
|
+
probeState: probe?.state ?? null,
|
|
731
|
+
probeCheckedAt: probe?.checkedAt ?? null,
|
|
732
|
+
statusSource: status === void 0 ? "derived" : "upstream-event"
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Assemble the complete snapshot from loader rows, tool groups, upstream
|
|
737
|
+
* facts, and probe rows. Tolerates missing fields anywhere in the inputs.
|
|
738
|
+
*
|
|
739
|
+
* @param input - the snapshot inputs (see {@link McpAggregateInput}).
|
|
740
|
+
* @returns the wire snapshot.
|
|
741
|
+
*/
|
|
742
|
+
function aggregateSnapshot(input) {
|
|
743
|
+
const { rows, groups, facts, probes, patchFile, refreshIntervalMs } = input;
|
|
744
|
+
const rowsByName = /* @__PURE__ */ new Map();
|
|
745
|
+
for (const row of rows) {
|
|
746
|
+
const name = serverNameOf(row.config, `entry:${row.entryId}`);
|
|
747
|
+
const existing = rowsByName.get(name);
|
|
748
|
+
if (existing === void 0 || !row.disabled && existing.disabled) rowsByName.set(name, row);
|
|
749
|
+
}
|
|
750
|
+
const groupsByName = new Map(groups.map((group) => [group.serverName, group]));
|
|
751
|
+
const servers = [.../* @__PURE__ */ new Set([...rowsByName.keys(), ...groupsByName.keys()])].map((name) => aggregateServerView(rowsByName.get(name), name, groupsByName.get(name), facts)).sort((left, right) => left.serverName < right.serverName ? -1 : 1);
|
|
752
|
+
return {
|
|
753
|
+
observed: facts.statuses.size > 0,
|
|
754
|
+
patchFile,
|
|
755
|
+
refreshIntervalMs,
|
|
756
|
+
servers,
|
|
757
|
+
probes
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
//#endregion
|
|
761
|
+
//#region src/grouping.ts
|
|
762
|
+
/** The `mcp__` namespace prefix every bridged MCP tool name starts with. */
|
|
763
|
+
const MCP_TOOL_PREFIX = "mcp__";
|
|
764
|
+
/**
|
|
765
|
+
* Split one registered tool schema list into per-server groups.
|
|
766
|
+
*
|
|
767
|
+
* @param schemas - the `ctx.tools.schemas()` snapshot (or any subset).
|
|
768
|
+
* @param configuredNames - server namespaces from the loader's mcp-client rows.
|
|
769
|
+
* @returns one group per configured namespace (even with zero tools) plus one
|
|
770
|
+
* group per unmatched `mcp__` namespace, sorted by server name.
|
|
771
|
+
*/
|
|
772
|
+
function groupMcpTools(schemas, configuredNames) {
|
|
773
|
+
const groups = /* @__PURE__ */ new Map();
|
|
774
|
+
const configured = new Set(configuredNames);
|
|
775
|
+
for (const serverName of configuredNames) if (!groups.has(serverName)) groups.set(serverName, {
|
|
776
|
+
serverName,
|
|
777
|
+
configured: true,
|
|
778
|
+
tools: []
|
|
779
|
+
});
|
|
780
|
+
for (const schema of schemas) {
|
|
781
|
+
const name = typeof schema.name === "string" ? schema.name : "";
|
|
782
|
+
if (!name.startsWith("mcp__")) continue;
|
|
783
|
+
const server = matchConfigured(name, configured);
|
|
784
|
+
const key = server ?? leftoverSegment(name);
|
|
785
|
+
if (key === "") continue;
|
|
786
|
+
const description = typeof schema.description === "string" ? schema.description : "";
|
|
787
|
+
const group = groups.get(key) ?? {
|
|
788
|
+
serverName: key,
|
|
789
|
+
configured: server !== void 0,
|
|
790
|
+
tools: []
|
|
791
|
+
};
|
|
792
|
+
group.tools.push({
|
|
793
|
+
name,
|
|
794
|
+
description
|
|
795
|
+
});
|
|
796
|
+
groups.set(key, group);
|
|
797
|
+
}
|
|
798
|
+
return [...groups.values()].map((group) => ({
|
|
799
|
+
...group,
|
|
800
|
+
tools: [...group.tools].sort((left, right) => left.name < right.name ? -1 : 1)
|
|
801
|
+
})).sort((left, right) => left.serverName < right.serverName ? -1 : 1);
|
|
802
|
+
}
|
|
803
|
+
/** Match `mcp__<server>__<raw>` against the configured namespaces. */
|
|
804
|
+
function matchConfigured(name, configured) {
|
|
805
|
+
for (const serverName of configured) if (name.startsWith(`mcp__${serverName}__`)) return serverName;
|
|
806
|
+
}
|
|
807
|
+
/** Best-effort namespace segment for a foreign `mcp__`-prefixed registration. */
|
|
808
|
+
function leftoverSegment(name) {
|
|
809
|
+
const separator = name.indexOf("__", 5);
|
|
810
|
+
return separator < 0 ? name.slice(5) : name.slice(5, separator);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Count the tools registered under one configured server namespace.
|
|
814
|
+
*
|
|
815
|
+
* @param schemas - the `ctx.tools.schemas()` snapshot.
|
|
816
|
+
* @param serverName - the configured namespace.
|
|
817
|
+
* @returns the number of matching registrations.
|
|
818
|
+
*/
|
|
819
|
+
function countServerTools(schemas, serverName) {
|
|
820
|
+
const prefix = `${MCP_TOOL_PREFIX}${serverName}__`;
|
|
821
|
+
let count = 0;
|
|
822
|
+
for (const schema of schemas) if (schema.name.startsWith(prefix)) count += 1;
|
|
823
|
+
return count;
|
|
824
|
+
}
|
|
825
|
+
//#endregion
|
|
826
|
+
//#region src/service.ts
|
|
827
|
+
/**
|
|
828
|
+
* The panel's host service: assembles the read-only MCP snapshot and serves
|
|
829
|
+
* it under the `mcpPanel` Typert Remote namespace (`mcpPanel/status`).
|
|
830
|
+
*
|
|
831
|
+
* Data sources, all read-only:
|
|
832
|
+
* - `ctx.loader` — mcp-client rows (raw config, effective disabled, fiber phase).
|
|
833
|
+
* - `ctx.tools.schemas()` — registered `mcp__<server>__` tool names + descriptions.
|
|
834
|
+
* - the proposed upstream `mcp/status` seam — observed via {@link observe}.
|
|
835
|
+
* - `ctx.jobs` — unowned `mcp-probe` background jobs (panel-only results).
|
|
836
|
+
*
|
|
837
|
+
* Connection status is reported honestly: without upstream observations the
|
|
838
|
+
* view reads `unknown` with `statusSource: 'derived'`; the panel never infers
|
|
839
|
+
* a connection state from tool-registry presence.
|
|
840
|
+
*
|
|
841
|
+
* @module dsh-mcp-panel/service
|
|
842
|
+
*/
|
|
843
|
+
/**
|
|
844
|
+
* Runtime mirror of the Cordis `FiberState` const enum (numeric cross-package
|
|
845
|
+
* const enums have no runtime import), projected to the wire phases.
|
|
846
|
+
*/
|
|
847
|
+
const FIBER_PHASE = {
|
|
848
|
+
0: "pending",
|
|
849
|
+
1: "loading",
|
|
850
|
+
2: "active",
|
|
851
|
+
3: "failed",
|
|
852
|
+
4: null,
|
|
853
|
+
5: "unloading"
|
|
854
|
+
};
|
|
855
|
+
/** The profile patch-layer filename the enable/disable suggestions name. */
|
|
856
|
+
const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
|
|
857
|
+
/** Label prefix written by the `mcp_probe` tool and the panel probe action. */
|
|
858
|
+
const PROBE_LABEL_PREFIX = "mcp_probe ";
|
|
859
|
+
/** Defaults for direct (non-Loader) service construction. */
|
|
860
|
+
const DEFAULT_SERVICE_CONFIG = {
|
|
861
|
+
probeTimeoutMs: 1e4,
|
|
862
|
+
maxProbes: 10,
|
|
863
|
+
refreshIntervalMs: 0,
|
|
864
|
+
passiveProbeEnabled: false,
|
|
865
|
+
passiveProbeIntervalMs: 6e4
|
|
866
|
+
};
|
|
867
|
+
/** Read-only MCP management snapshot service, exported over the `mcpPanel` Remote namespace. */
|
|
868
|
+
var McpPanelService = class extends TypertRemoteService {
|
|
869
|
+
config;
|
|
870
|
+
static inject = ["loader", "tools"];
|
|
871
|
+
/** Latest upstream payload per server namespace. */
|
|
872
|
+
statuses = /* @__PURE__ */ new Map();
|
|
873
|
+
/** Cumulative reconnect attempts observed per server namespace. */
|
|
874
|
+
reconnects = /* @__PURE__ */ new Map();
|
|
875
|
+
/** Epoch ms of the latest upstream event receipt per server namespace. */
|
|
876
|
+
observedAt = /* @__PURE__ */ new Map();
|
|
877
|
+
/** Latest passive-probe reachability per server namespace. */
|
|
878
|
+
probeStates = /* @__PURE__ */ new Map();
|
|
879
|
+
/** Passive-probe loop guard: one sweep at a time. */
|
|
880
|
+
passiveRunning = false;
|
|
881
|
+
/**
|
|
882
|
+
* @param ctx - context carrying the loader and tool registry.
|
|
883
|
+
* @param config - resolved runtime settings; defaults apply for direct construction.
|
|
884
|
+
*/
|
|
885
|
+
constructor(ctx, config = DEFAULT_SERVICE_CONFIG) {
|
|
886
|
+
super(ctx, "mcpPanel");
|
|
887
|
+
this.config = config;
|
|
888
|
+
if (config.passiveProbeEnabled) {
|
|
889
|
+
const timer = setInterval(() => {
|
|
890
|
+
this.runPassiveProbes();
|
|
891
|
+
}, config.passiveProbeIntervalMs);
|
|
892
|
+
timer.unref?.();
|
|
893
|
+
ctx.effect(() => () => {
|
|
894
|
+
clearInterval(timer);
|
|
895
|
+
}, "dsh-mcp-panel: passive probe loop");
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Record one upstream `mcp/status` payload (event or query-seed). A
|
|
900
|
+
* `connecting` payload with a positive attempt counts one reconnect.
|
|
901
|
+
*
|
|
902
|
+
* @param payload - post-transition status facts.
|
|
903
|
+
*/
|
|
904
|
+
observe(payload) {
|
|
905
|
+
if (typeof payload.serverName !== "string" || payload.serverName === "") return;
|
|
906
|
+
this.statuses.set(payload.serverName, payload);
|
|
907
|
+
this.observedAt.set(payload.serverName, Date.now());
|
|
908
|
+
if (payload.phase === "connecting" && payload.attempt > 0) this.reconnects.set(payload.serverName, (this.reconnects.get(payload.serverName) ?? 0) + 1);
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Assemble the current snapshot. Read-only: touches no configuration file
|
|
912
|
+
* and mutates no registry. Exported on the wire by the `mcpPanel/status`
|
|
913
|
+
* invocation descriptor in `./wire.ts` (registered through the package's
|
|
914
|
+
* `./typert` manifest) — no method decorator, so the built bundle stays
|
|
915
|
+
* plain ESM.
|
|
916
|
+
*
|
|
917
|
+
* @returns the wire snapshot (validated by the strict Typert codec on both faces).
|
|
918
|
+
*/
|
|
919
|
+
status() {
|
|
920
|
+
const rows = [];
|
|
921
|
+
for (const entry of this.ctx.loader.entries()) {
|
|
922
|
+
if (entry.options.name !== "@deepseek-ai/dsh-mcp-client") continue;
|
|
923
|
+
rows.push({
|
|
924
|
+
entryId: entry.options.id,
|
|
925
|
+
disabled: entry.disabled,
|
|
926
|
+
fiberPhase: entry.fiber === void 0 ? null : FIBER_PHASE[entry.fiber.state] ?? null,
|
|
927
|
+
config: entry.options.config
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
return aggregateSnapshot({
|
|
931
|
+
rows,
|
|
932
|
+
groups: groupMcpTools(this.ctx.tools.schemas(), rows.map((row) => serverNameOf(row.config, `entry:${row.entryId}`))),
|
|
933
|
+
facts: {
|
|
934
|
+
statuses: this.statuses,
|
|
935
|
+
reconnects: this.reconnects,
|
|
936
|
+
observedAt: this.observedAt,
|
|
937
|
+
probeStates: this.probeStates
|
|
938
|
+
},
|
|
939
|
+
probes: this.probeViews(),
|
|
940
|
+
patchFile: this.patchFile(),
|
|
941
|
+
refreshIntervalMs: this.config.refreshIntervalMs
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Start a one-shot connectivity probe of one configured streamable-http
|
|
946
|
+
* server as an UNOWNED background job — panel-only, like the `mcp_probe`
|
|
947
|
+
* tool, but callable from the settings tab. Exported on the wire by the
|
|
948
|
+
* `mcpPanel/probe` invocation descriptor.
|
|
949
|
+
*
|
|
950
|
+
* @param serverName - configured namespace.
|
|
951
|
+
* @returns the started job id and where the result lands.
|
|
952
|
+
*/
|
|
953
|
+
probe(serverName) {
|
|
954
|
+
const target = this.rawEndpoint(serverName);
|
|
955
|
+
if (target === void 0) throw new Error(`dsh-mcp-panel: "${serverName}" is not a configured streamable-http MCP server`);
|
|
956
|
+
const jobs = this.ctx.get("jobs");
|
|
957
|
+
if (jobs === void 0) throw new Error("dsh-mcp-panel: ctx.jobs is not composed — the panel probe action needs a background-job registry");
|
|
958
|
+
return {
|
|
959
|
+
jobId: jobs.start({
|
|
960
|
+
kind: PROBE_KIND,
|
|
961
|
+
label: `mcp_probe ${serverName}`,
|
|
962
|
+
run: () => probeJob(target.url, target.headers, this.config.probeTimeoutMs)
|
|
963
|
+
}),
|
|
964
|
+
note: "Probe results are panel-only: Settings → Plugins → MCP."
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
/** One passive-probe sweep over every configured streamable-http server. */
|
|
968
|
+
async runPassiveProbes() {
|
|
969
|
+
if (this.passiveRunning) return;
|
|
970
|
+
this.passiveRunning = true;
|
|
971
|
+
try {
|
|
972
|
+
for (const entry of this.ctx.loader.entries()) {
|
|
973
|
+
if (entry.options.name !== "@deepseek-ai/dsh-mcp-client") continue;
|
|
974
|
+
const serverName = serverNameOf(entry.options.config, `entry:${entry.options.id}`);
|
|
975
|
+
const target = this.rawEndpoint(serverName);
|
|
976
|
+
if (target === void 0) continue;
|
|
977
|
+
const outcome = await probeEndpoint(target.url, target.headers, this.config.probeTimeoutMs, AbortSignal.timeout(this.config.probeTimeoutMs));
|
|
978
|
+
this.probeStates.set(serverName, {
|
|
979
|
+
state: outcome.status === "completed" ? "reachable" : "unreachable",
|
|
980
|
+
checkedAt: Date.now()
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
} finally {
|
|
984
|
+
this.passiveRunning = false;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Resolve one server's raw endpoint for the probe tool. Credentials stay
|
|
989
|
+
* inside this return value and are used for the request only — they never
|
|
990
|
+
* reach a snapshot, a log, or a display.
|
|
991
|
+
*
|
|
992
|
+
* @param serverName - configured namespace.
|
|
993
|
+
* @returns the raw URL + configured headers, or `undefined` when the server
|
|
994
|
+
* is not a configured streamable-http row.
|
|
995
|
+
*/
|
|
996
|
+
rawEndpoint(serverName) {
|
|
997
|
+
for (const entry of this.ctx.loader.entries()) {
|
|
998
|
+
if (entry.options.name !== "@deepseek-ai/dsh-mcp-client") continue;
|
|
999
|
+
const config = entry.options.config;
|
|
1000
|
+
if (serverNameOf(config, `entry:${entry.id}`) !== serverName) continue;
|
|
1001
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) return void 0;
|
|
1002
|
+
const row = config;
|
|
1003
|
+
if (row["transport"] !== "streamable-http") return void 0;
|
|
1004
|
+
const url = row["url"];
|
|
1005
|
+
if (typeof url !== "string" || url === "") return void 0;
|
|
1006
|
+
const headersValue = row["headers"];
|
|
1007
|
+
const headers = {};
|
|
1008
|
+
if (typeof headersValue === "object" && headersValue !== null && !Array.isArray(headersValue)) {
|
|
1009
|
+
for (const [name, value] of Object.entries(headersValue)) if (typeof value === "string") headers[name] = value;
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
url,
|
|
1013
|
+
headers
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/** Unowned `mcp-probe` background jobs, newest first, sanitized for display. */
|
|
1018
|
+
probeViews() {
|
|
1019
|
+
const jobs = this.ctx.get("jobs");
|
|
1020
|
+
if (jobs === void 0) return [];
|
|
1021
|
+
return jobs.list().filter((job) => job.kind === PROBE_KIND).map((job) => ({
|
|
1022
|
+
id: job.id,
|
|
1023
|
+
serverName: job.label.startsWith(PROBE_LABEL_PREFIX) ? job.label.slice(10) : job.label,
|
|
1024
|
+
status: job.status,
|
|
1025
|
+
startedAt: job.startedAt,
|
|
1026
|
+
finishedAt: job.finishedAt ?? null,
|
|
1027
|
+
detail: job.detail === void 0 ? null : sanitizeText(job.detail)
|
|
1028
|
+
})).reverse().slice(0, this.config.maxProbes);
|
|
1029
|
+
}
|
|
1030
|
+
/** Absolute path of the profile patch layer the suggestions name, or null. */
|
|
1031
|
+
patchFile() {
|
|
1032
|
+
const base = this.ctx.baseUrl;
|
|
1033
|
+
if (typeof base !== "string" || base === "") return null;
|
|
1034
|
+
const dir = base.startsWith("file://") ? fileURLToPath(base) : base;
|
|
1035
|
+
return join(dir, PROFILE_PATCH_FILENAME);
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/upstream.ts
|
|
1040
|
+
/** Exact event name, exported so consumers never hardcode the literal twice. */
|
|
1041
|
+
const MCP_STATUS_EVENT = "mcp/status";
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/index.ts
|
|
1044
|
+
const name = "mcp-panel";
|
|
1045
|
+
/** Hard services: the facts the panel reads. `commands`/`jobs` are optional children. */
|
|
1046
|
+
const inject = ["tools", "loader"];
|
|
1047
|
+
/**
|
|
1048
|
+
* Mount the panel: the snapshot service, the upstream status seam consumer,
|
|
1049
|
+
* the `/mcp` command (when commands exist), and the probe tool (when enabled
|
|
1050
|
+
* and a job registry exists).
|
|
1051
|
+
*
|
|
1052
|
+
* @param ctx - context carrying tools + loader.
|
|
1053
|
+
* @param config - raw loader config; defaults applied through {@link resolveConfig}.
|
|
1054
|
+
*/
|
|
1055
|
+
async function apply(ctx, config) {
|
|
1056
|
+
const resolved = resolveConfig(config);
|
|
1057
|
+
await ctx.plugin(McpPanelService, {
|
|
1058
|
+
probeTimeoutMs: resolved.probeTimeoutMs,
|
|
1059
|
+
maxProbes: resolved.maxProbes,
|
|
1060
|
+
refreshIntervalMs: resolved.refreshIntervalMs,
|
|
1061
|
+
passiveProbeEnabled: resolved.passiveProbeEnabled,
|
|
1062
|
+
passiveProbeIntervalMs: resolved.passiveProbeIntervalMs
|
|
1063
|
+
});
|
|
1064
|
+
const service = ctx.get("mcpPanel");
|
|
1065
|
+
ctx.on(MCP_STATUS_EVENT, (payload) => {
|
|
1066
|
+
service.observe(payload);
|
|
1067
|
+
});
|
|
1068
|
+
const query = ctx.get("mcpStatus");
|
|
1069
|
+
if (query !== void 0) for (const status of query.list()) service.observe(status);
|
|
1070
|
+
ctx.inject(["commands"], (scope) => {
|
|
1071
|
+
scope.effect(() => scope.commands.register(mcpCommand(service, resolved.outputLanguage)), "dsh-mcp-panel: /mcp command");
|
|
1072
|
+
});
|
|
1073
|
+
ctx.inject(["jobs"], (scope) => {
|
|
1074
|
+
scope.effect(() => scope.jobs.attachController("dsh-mcp-panel"), "dsh-mcp-panel: jobs controller");
|
|
1075
|
+
if (resolved.probeEnabled) scope.effect(() => scope.tools.register(mcpProbeTool(service, scope.jobs, resolved.probeTimeoutMs)), "dsh-mcp-panel: probe tool");
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
//#endregion
|
|
1079
|
+
export { Config, MCP_STATUS_EVENT, McpPanelService, aggregateServerView, aggregateSnapshot, apply, countServerTools, deriveTarget, groupMcpTools, inject, mcpCommand, mcpProbeTool, name, parseMcpArgs, probeEndpoint, probeJob, renderList, renderPatchSuggestion, renderServer, renderTools, resolveConfig, sanitizeError, sanitizeText, sanitizeUrl, serverNameOf };
|