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/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-mcp-panel` — read-only runtime management panel for the official
|
|
3
|
+
* DeepSeek Harness MCP client (`@deepseek-ai/dsh-mcp-client`).
|
|
4
|
+
*
|
|
5
|
+
* Host half: mounts the `mcpPanel` Remote service (loader rows + tool
|
|
6
|
+
* registry + upstream `mcp/status` observations), registers the `/mcp`
|
|
7
|
+
* command where a command registry exists, and optionally registers the
|
|
8
|
+
* `mcp_probe` background-job tool where a job registry exists. The panel
|
|
9
|
+
* never edits configuration files and never fabricates connection state:
|
|
10
|
+
* without the proposed upstream status seam every connection field reads
|
|
11
|
+
* `unknown` with `statusSource: 'derived'`.
|
|
12
|
+
*
|
|
13
|
+
* Function plugin — no default export (the Loader unwraps
|
|
14
|
+
* `exports.default ?? exports`).
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-mcp-panel
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
20
|
+
import type {} from '@deepseek-ai/dsh-commands'
|
|
21
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
22
|
+
import type {} from '@deepseek-ai/dsh-jobs'
|
|
23
|
+
import { Config, resolveConfig } from './config.ts'
|
|
24
|
+
import { mcpCommand } from './command.ts'
|
|
25
|
+
import { mcpProbeTool } from './probe.ts'
|
|
26
|
+
import { McpPanelService } from './service.ts'
|
|
27
|
+
import { MCP_STATUS_EVENT, type McpStatusQuery } from './upstream.ts'
|
|
28
|
+
|
|
29
|
+
export const name = 'mcp-panel'
|
|
30
|
+
|
|
31
|
+
/** Hard services: the facts the panel reads. `commands`/`jobs` are optional children. */
|
|
32
|
+
export const inject = ['tools', 'loader']
|
|
33
|
+
|
|
34
|
+
export { Config, resolveConfig } from './config.ts'
|
|
35
|
+
export { McpPanelService } from './service.ts'
|
|
36
|
+
export { mcpCommand, parseMcpArgs, renderList, renderPatchSuggestion, renderServer, renderTools } from './command.ts'
|
|
37
|
+
export { groupMcpTools, countServerTools } from './grouping.ts'
|
|
38
|
+
export { aggregateServerView, aggregateSnapshot, deriveTarget, serverNameOf } from './aggregate.ts'
|
|
39
|
+
export { sanitizeError, sanitizeText, sanitizeUrl } from './sanitize.ts'
|
|
40
|
+
export { probeEndpoint, probeJob, mcpProbeTool } from './probe.ts'
|
|
41
|
+
export { MCP_STATUS_EVENT, type McpStatusPayload, type McpStatusQuery, type McpServerStatus } from './upstream.ts'
|
|
42
|
+
export type * from './wire.ts'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mount the panel: the snapshot service, the upstream status seam consumer,
|
|
46
|
+
* the `/mcp` command (when commands exist), and the probe tool (when enabled
|
|
47
|
+
* and a job registry exists).
|
|
48
|
+
*
|
|
49
|
+
* @param ctx - context carrying tools + loader.
|
|
50
|
+
* @param config - raw loader config; defaults applied through {@link resolveConfig}.
|
|
51
|
+
*/
|
|
52
|
+
export async function apply(ctx: Context, config: Config): Promise<void> {
|
|
53
|
+
const resolved = resolveConfig(config)
|
|
54
|
+
|
|
55
|
+
// The service has injects, so its fiber activates asynchronously — await it
|
|
56
|
+
// before reading the instance the command and probe closures capture.
|
|
57
|
+
await ctx.plugin(McpPanelService, {
|
|
58
|
+
probeTimeoutMs: resolved.probeTimeoutMs,
|
|
59
|
+
maxProbes: resolved.maxProbes,
|
|
60
|
+
refreshIntervalMs: resolved.refreshIntervalMs,
|
|
61
|
+
passiveProbeEnabled: resolved.passiveProbeEnabled,
|
|
62
|
+
passiveProbeIntervalMs: resolved.passiveProbeIntervalMs,
|
|
63
|
+
})
|
|
64
|
+
const service = ctx.get('mcpPanel') as McpPanelService
|
|
65
|
+
|
|
66
|
+
// Consume the proposed upstream seam: live events plus a one-shot query
|
|
67
|
+
// seed from the (optional) status service when it is already mounted.
|
|
68
|
+
ctx.on(MCP_STATUS_EVENT, (payload) => { service.observe(payload) })
|
|
69
|
+
const query = ctx.get('mcpStatus') as McpStatusQuery | undefined
|
|
70
|
+
if (query !== undefined) {
|
|
71
|
+
for (const status of query.list()) service.observe(status)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The /mcp command: only where a human-command registry is composed.
|
|
75
|
+
ctx.inject(['commands'], (scope) => {
|
|
76
|
+
scope.effect(() => scope.commands.register(mcpCommand(service, resolved.outputLanguage)), 'dsh-mcp-panel: /mcp command')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// The jobs controller serves the panel probe action AND the optional tool.
|
|
80
|
+
ctx.inject(['jobs'], (scope) => {
|
|
81
|
+
scope.effect(() => scope.jobs.attachController('dsh-mcp-panel'), 'dsh-mcp-panel: jobs controller')
|
|
82
|
+
if (resolved.probeEnabled) {
|
|
83
|
+
scope.effect(() => scope.tools.register(mcpProbeTool(service, scope.jobs, resolved.probeTimeoutMs)), 'dsh-mcp-panel: probe tool')
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional `mcp_probe` tool: a one-shot connectivity probe of one
|
|
3
|
+
* configured Streamable HTTP endpoint, executed as an UNOWNED background job.
|
|
4
|
+
* Probe results are panel-only — the tool returns just the job id and a
|
|
5
|
+
* pointer to the settings tab, the job carries no owner (so no completion
|
|
6
|
+
* notice is injected into the model), and the panel reads the sanitized
|
|
7
|
+
* snapshot back through `mcpPanel/status`.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-mcp-panel/probe
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { JobHooks, JobOutcome, JobRegistry } from '@deepseek-ai/dsh-jobs'
|
|
13
|
+
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
14
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
|
+
import { sanitizeError, sanitizeText } from './sanitize.ts'
|
|
16
|
+
import type { McpPanelService } from './service.ts'
|
|
17
|
+
|
|
18
|
+
/** Producer kind; also the job-id prefix. */
|
|
19
|
+
export const PROBE_KIND = 'mcp-probe'
|
|
20
|
+
|
|
21
|
+
declare module '@deepseek-ai/dsh-jobs' {
|
|
22
|
+
interface JobKindMap {
|
|
23
|
+
/** One-shot Streamable HTTP connectivity probe (panel-only results). */
|
|
24
|
+
'mcp-probe': 'mcp-probe'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** JSON-RPC MCP initialize request used by the probe (protocol constant). */
|
|
29
|
+
const INITIALIZE_PROTOCOL_VERSION = '2024-11-05'
|
|
30
|
+
|
|
31
|
+
/** MCP clientInfo facts; protocol constants, not configuration. Exported so the
|
|
32
|
+
* version-consistency tripwire (`tests/version.spec.ts`) can assert the
|
|
33
|
+
* advertised version tracks the package version. */
|
|
34
|
+
export const PROBE_CLIENT_INFO = { name: 'dsh-mcp-panel', version: '0.2.0' }
|
|
35
|
+
|
|
36
|
+
/** One settled probe: outcome status plus a sanitized one-line detail. */
|
|
37
|
+
export interface ProbeOutcome {
|
|
38
|
+
/** How the job ended. */
|
|
39
|
+
status: 'completed' | 'failed'
|
|
40
|
+
/** Sanitized one-line detail (HTTP status, latency, server info, or error). */
|
|
41
|
+
detail: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Display cap for server-reported name/version fields in probe details. */
|
|
45
|
+
const DISPLAY_LIMIT = 80
|
|
46
|
+
|
|
47
|
+
/** Bound one display string so hostile server metadata cannot blow up layouts. */
|
|
48
|
+
function boundedDisplay(value: string): string {
|
|
49
|
+
return value.length <= DISPLAY_LIMIT ? value : `${value.slice(0, DISPLAY_LIMIT - 1)}…`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* POST one MCP `initialize` request and describe the outcome in one sanitized
|
|
54
|
+
* line. Never sends or echoes credentials: the configured headers are used
|
|
55
|
+
* for the request itself (exactly as the bridge would) and never rendered.
|
|
56
|
+
*
|
|
57
|
+
* @param url - endpoint URL (already parsed by the caller).
|
|
58
|
+
* @param headers - the configured request headers; used, never displayed.
|
|
59
|
+
* @param timeoutMs - probe deadline.
|
|
60
|
+
* @param signal - caller-owned abort (job kill or timeout).
|
|
61
|
+
* @returns the settled probe outcome.
|
|
62
|
+
*/
|
|
63
|
+
export async function probeEndpoint(
|
|
64
|
+
url: string,
|
|
65
|
+
headers: Readonly<Record<string, string>>,
|
|
66
|
+
timeoutMs: number,
|
|
67
|
+
signal: AbortSignal,
|
|
68
|
+
): Promise<ProbeOutcome> {
|
|
69
|
+
const started = Date.now()
|
|
70
|
+
try {
|
|
71
|
+
const response = await fetch(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'content-type': 'application/json',
|
|
75
|
+
accept: 'application/json, text/event-stream',
|
|
76
|
+
...headers,
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
jsonrpc: '2.0',
|
|
80
|
+
id: 1,
|
|
81
|
+
method: 'initialize',
|
|
82
|
+
params: {
|
|
83
|
+
protocolVersion: INITIALIZE_PROTOCOL_VERSION,
|
|
84
|
+
capabilities: {},
|
|
85
|
+
clientInfo: PROBE_CLIENT_INFO,
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
signal,
|
|
89
|
+
})
|
|
90
|
+
const ms = Date.now() - started
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
return { status: 'failed', detail: `HTTP ${response.status} ${response.statusText} (${ms}ms)` }
|
|
93
|
+
}
|
|
94
|
+
let serverInfo: { name?: unknown; version?: unknown } = {}
|
|
95
|
+
try {
|
|
96
|
+
const body = (await response.json()) as { result?: { serverInfo?: { name?: unknown; version?: unknown } } }
|
|
97
|
+
serverInfo = body?.result?.serverInfo ?? {}
|
|
98
|
+
} catch {
|
|
99
|
+
// A 2xx without a JSON body: connectivity itself succeeded.
|
|
100
|
+
}
|
|
101
|
+
const name = typeof serverInfo.name === 'string' && serverInfo.name !== ''
|
|
102
|
+
? boundedDisplay(sanitizeText(serverInfo.name))
|
|
103
|
+
: 'unnamed'
|
|
104
|
+
const version = typeof serverInfo.version === 'string' && serverInfo.version !== ''
|
|
105
|
+
? boundedDisplay(sanitizeText(serverInfo.version))
|
|
106
|
+
: 'unknown version'
|
|
107
|
+
return { status: 'completed', detail: `HTTP ${response.status}, MCP initialize ok (server ${name} ${version}) in ${ms}ms` }
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (signal.aborted) return { status: 'failed', detail: `timeout after ${timeoutMs}ms or cancelled` }
|
|
110
|
+
return { status: 'failed', detail: sanitizeError(error) }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create the background-job hooks for one probe: cancel aborts the fetch, the
|
|
116
|
+
* outcome settles `done` with sanitized detail only.
|
|
117
|
+
*
|
|
118
|
+
* @param url - endpoint URL.
|
|
119
|
+
* @param headers - configured headers (used, never rendered).
|
|
120
|
+
* @param timeoutMs - probe deadline.
|
|
121
|
+
* @returns the registry hooks.
|
|
122
|
+
*/
|
|
123
|
+
export function probeJob(url: string, headers: Readonly<Record<string, string>>, timeoutMs: number): JobHooks {
|
|
124
|
+
const controller = new AbortController()
|
|
125
|
+
const timer = setTimeout(() => { controller.abort() }, timeoutMs)
|
|
126
|
+
timer.unref?.()
|
|
127
|
+
const done: Promise<JobOutcome> = probeEndpoint(url, headers, timeoutMs, controller.signal)
|
|
128
|
+
.then(outcome => ({ ...outcome }))
|
|
129
|
+
.finally(() => { clearTimeout(timer) })
|
|
130
|
+
return {
|
|
131
|
+
cancel: () => { controller.abort() },
|
|
132
|
+
done,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Resolve one configured server's raw endpoint for probing. */
|
|
137
|
+
function probeTarget(
|
|
138
|
+
service: McpPanelService,
|
|
139
|
+
server: string,
|
|
140
|
+
): { url: string; headers: Record<string, string> } | undefined {
|
|
141
|
+
return service.rawEndpoint(server)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build the `mcp_probe` tool definition.
|
|
146
|
+
*
|
|
147
|
+
* @param service - panel service (server lookup + snapshot for panel display).
|
|
148
|
+
* @param jobs - background-job registry the probe runs on.
|
|
149
|
+
* @param timeoutMs - per-probe deadline.
|
|
150
|
+
* @returns the registration-ready definition.
|
|
151
|
+
*/
|
|
152
|
+
export function mcpProbeTool(service: McpPanelService, jobs: JobRegistry, timeoutMs: number): ToolDefinition {
|
|
153
|
+
return defineTool({
|
|
154
|
+
name: 'mcp_probe',
|
|
155
|
+
description: 'Run a one-shot connectivity probe of a configured streamable-http MCP server as a background job. '
|
|
156
|
+
+ 'Results appear in the MCP settings panel only — they are not injected into model context.',
|
|
157
|
+
parameters: {
|
|
158
|
+
server: {
|
|
159
|
+
type: 'string',
|
|
160
|
+
required: true,
|
|
161
|
+
description: 'serverName of a configured streamable-http MCP server (see /mcp for the list).',
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
output: {
|
|
165
|
+
schema: {
|
|
166
|
+
type: 'object',
|
|
167
|
+
additionalProperties: false,
|
|
168
|
+
properties: {
|
|
169
|
+
jobId: { type: 'string', required: true },
|
|
170
|
+
note: { type: 'string', required: true },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
render: (_args, value) => [{
|
|
174
|
+
type: 'text',
|
|
175
|
+
text: `Probe started (background job ${value.jobId}). Read the result in the MCP panel: Settings → Plugins → MCP.`,
|
|
176
|
+
}],
|
|
177
|
+
},
|
|
178
|
+
async execute(args) {
|
|
179
|
+
const target = probeTarget(service, args.server)
|
|
180
|
+
if (target === undefined) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`mcp_probe: "${args.server}" is not a configured streamable-http MCP server (see /mcp). `
|
|
183
|
+
+ 'stdio servers have no HTTP endpoint to probe.',
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
const jobId = jobs.start({
|
|
187
|
+
kind: PROBE_KIND,
|
|
188
|
+
label: `mcp_probe ${args.server}`,
|
|
189
|
+
// Unowned: no model completion notice, readable by the panel only.
|
|
190
|
+
run: () => probeJob(target.url, target.headers, timeoutMs),
|
|
191
|
+
})
|
|
192
|
+
return {
|
|
193
|
+
jobId,
|
|
194
|
+
note: 'Probe results are panel-only: Settings → Plugins → MCP.',
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
}
|
package/src/sanitize.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display sanitization for MCP panel surfaces. Everything the panel shows —
|
|
3
|
+
* the `/mcp` command output, the web settings tab, probe job details — passes
|
|
4
|
+
* through these pure functions so URL query-string credentials, userinfo
|
|
5
|
+
* passwords, header values, and bearer tokens never reach a display.
|
|
6
|
+
*
|
|
7
|
+
* The panel never shows configured `headers` at all (they are dropped at
|
|
8
|
+
* snapshot assembly); this module redacts what can still leak through target
|
|
9
|
+
* URLs and error text.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-mcp-panel/sanitize
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Replacement for every redacted credential value. */
|
|
15
|
+
export const REDACTED = '***'
|
|
16
|
+
|
|
17
|
+
/** Query/field keys whose values are credentials regardless of their name casing. */
|
|
18
|
+
const CREDENTIAL_KEY = /^(?:access[_-]?token|api[_-]?key|apikey|auth|authorization|client[_-]?secret|key|password|passwd|pwd|secret|sig|signature|token)$/iu
|
|
19
|
+
|
|
20
|
+
/** Credential keys for the unparseable-URL fallback and embedded-text scans. */
|
|
21
|
+
const CREDENTIAL_KEY_SOURCE = '(?:access[_-]?token|api[_-]?key|apikey|auth(?:orization)?|client[_-]?secret|key|passw(?:or)?d|passwd|pwd|secret|sig(?:nature)?|token)'
|
|
22
|
+
|
|
23
|
+
/** Whole userinfo before `@` (unparseable URLs only — parsed URLs redact just the password). */
|
|
24
|
+
const USERINFO = /([a-z][a-z0-9+.-]*:\/\/)([^/@\s]+)@/giu
|
|
25
|
+
|
|
26
|
+
/** `?key=value` / `&key=value` credential pairs inside arbitrary text. */
|
|
27
|
+
const QUERY_CREDENTIAL = new RegExp(`([?&](?:[^=&#\\s]*${CREDENTIAL_KEY_SOURCE}[^=&#\\s]*)=)[^&#\\s]*`, 'giu')
|
|
28
|
+
|
|
29
|
+
/** `#key=value` credential pairs in URL fragments and arbitrary text. */
|
|
30
|
+
const FRAGMENT_CREDENTIAL = new RegExp(`(#[^=&#\\s]*${CREDENTIAL_KEY_SOURCE}[^=&#\\s]*=)[^&#\\s]*`, 'giu')
|
|
31
|
+
|
|
32
|
+
/** `Authorization: <value>`-style header lines in arbitrary text (quoted value first). */
|
|
33
|
+
const HEADER_CREDENTIAL_QUOTED = new RegExp(`(\\b${CREDENTIAL_KEY_SOURCE}\\s*[:=]\\s*["'])[^"']*(["'])`, 'giu')
|
|
34
|
+
|
|
35
|
+
/** `Authorization: <value>`-style header lines with unquoted values. */
|
|
36
|
+
const HEADER_CREDENTIAL_BARE = new RegExp(`(\\b${CREDENTIAL_KEY_SOURCE}\\s*[:=]\\s*)[^\\s,;)\\]}]+`, 'giu')
|
|
37
|
+
|
|
38
|
+
/** Environment-variable-shaped credentials (`GITHUB_TOKEN=…`) in spawn errors. */
|
|
39
|
+
const ENV_VAR_CREDENTIAL = /\b[A-Za-z0-9_]*(?:TOKEN|API[_-]?KEY|SECRET|PASSWORD|PASSWD)[A-Za-z0-9_]*\s*=\s*[^\s,;)\]}]+/gu
|
|
40
|
+
|
|
41
|
+
/** Bearer tokens, including the `Bearer ` keyword and the token itself. */
|
|
42
|
+
const BEARER = /(bearer)\s+[A-Za-z0-9._~+/=-]+/giu
|
|
43
|
+
|
|
44
|
+
/** Quoted JSON-ish `"token": "value"` pairs in arbitrary text. */
|
|
45
|
+
const QUOTED_CREDENTIAL = new RegExp(`(["'](?:access[_-]?token|api[_-]?key|client[_-]?secret|secret|token)["']\\s*[:=]\\s*["'])[^"']*(["'])`, 'giu')
|
|
46
|
+
|
|
47
|
+
/** Raw JWT bodies, wherever they appear. */
|
|
48
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/gu
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Redact a URL for display: userinfo password, credential query values, and
|
|
52
|
+
* credential fragment pairs. Query keys are read through `URLSearchParams`,
|
|
53
|
+
* so percent-encoded key names are decoded before matching. Unparseable
|
|
54
|
+
* inputs fall back to pattern redaction (whole userinfo, credential query
|
|
55
|
+
* pairs, credential fragment pairs) instead of throwing.
|
|
56
|
+
*
|
|
57
|
+
* @param url - candidate URL text.
|
|
58
|
+
* @returns display-safe URL text.
|
|
59
|
+
*/
|
|
60
|
+
export function sanitizeUrl(url: string): string {
|
|
61
|
+
let parsed: URL
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(url)
|
|
64
|
+
} catch {
|
|
65
|
+
return url
|
|
66
|
+
.replace(USERINFO, '$1***@')
|
|
67
|
+
.replace(QUERY_CREDENTIAL, `$1${REDACTED}`)
|
|
68
|
+
.replace(FRAGMENT_CREDENTIAL, `$1${REDACTED}`)
|
|
69
|
+
}
|
|
70
|
+
if (parsed.password !== '') parsed.password = REDACTED
|
|
71
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
72
|
+
if (CREDENTIAL_KEY.test(key)) parsed.searchParams.set(key, REDACTED)
|
|
73
|
+
}
|
|
74
|
+
if (parsed.hash !== '') parsed.hash = parsed.hash.replace(FRAGMENT_CREDENTIAL, `$1${REDACTED}`)
|
|
75
|
+
return parsed.toString()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Redact credential-shaped fragments from free text: header lines, bearer
|
|
80
|
+
* tokens, raw JWTs, embedded query pairs, and quoted token values.
|
|
81
|
+
*
|
|
82
|
+
* @param text - candidate display text.
|
|
83
|
+
* @returns display-safe text.
|
|
84
|
+
*/
|
|
85
|
+
export function sanitizeText(text: string): string {
|
|
86
|
+
return text
|
|
87
|
+
.replace(BEARER, `$1 ${REDACTED}`)
|
|
88
|
+
.replace(HEADER_CREDENTIAL_QUOTED, `$1${REDACTED}$2`)
|
|
89
|
+
.replace(HEADER_CREDENTIAL_BARE, `$1${REDACTED}`)
|
|
90
|
+
.replace(ENV_VAR_CREDENTIAL, value => {
|
|
91
|
+
const equals = value.indexOf('=')
|
|
92
|
+
return equals < 0 ? value : `${value.slice(0, equals)}=${REDACTED}`
|
|
93
|
+
})
|
|
94
|
+
.replace(QUOTED_CREDENTIAL, `$1${REDACTED}$2`)
|
|
95
|
+
.replace(QUERY_CREDENTIAL, `$1${REDACTED}`)
|
|
96
|
+
.replace(FRAGMENT_CREDENTIAL, `$1${REDACTED}`)
|
|
97
|
+
.replace(JWT, REDACTED)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Stringify an arbitrary thrown value safely and redact it for display.
|
|
102
|
+
* Never throws: unrenderable values degrade to a fixed marker.
|
|
103
|
+
*
|
|
104
|
+
* @param error - thrown value from a connection attempt, probe, or sync.
|
|
105
|
+
* @returns display-safe error text.
|
|
106
|
+
*/
|
|
107
|
+
export function sanitizeError(error: unknown): string {
|
|
108
|
+
let text: string
|
|
109
|
+
try {
|
|
110
|
+
text = typeof error === 'string' ? error : String(error)
|
|
111
|
+
} catch {
|
|
112
|
+
text = '<unrenderable error>'
|
|
113
|
+
}
|
|
114
|
+
return sanitizeText(text)
|
|
115
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel's host service: assembles the read-only MCP snapshot and serves
|
|
3
|
+
* it under the `mcpPanel` Typert Remote namespace (`mcpPanel/status`).
|
|
4
|
+
*
|
|
5
|
+
* Data sources, all read-only:
|
|
6
|
+
* - `ctx.loader` — mcp-client rows (raw config, effective disabled, fiber phase).
|
|
7
|
+
* - `ctx.tools.schemas()` — registered `mcp__<server>__` tool names + descriptions.
|
|
8
|
+
* - the proposed upstream `mcp/status` seam — observed via {@link observe}.
|
|
9
|
+
* - `ctx.jobs` — unowned `mcp-probe` background jobs (panel-only results).
|
|
10
|
+
*
|
|
11
|
+
* Connection status is reported honestly: without upstream observations the
|
|
12
|
+
* view reads `unknown` with `statusSource: 'derived'`; the panel never infers
|
|
13
|
+
* a connection state from tool-registry presence.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-mcp-panel/service
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
21
|
+
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
22
|
+
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
23
|
+
// Type-only: activates the `ctx.jobs` Context merge.
|
|
24
|
+
import type {} from '@deepseek-ai/dsh-jobs'
|
|
25
|
+
// Type-only: activates the `mcp-probe` JobKindMap extension.
|
|
26
|
+
import type {} from './probe.ts'
|
|
27
|
+
import {
|
|
28
|
+
aggregateSnapshot,
|
|
29
|
+
MCP_CLIENT_MODULE,
|
|
30
|
+
serverNameOf,
|
|
31
|
+
type McpLoaderRow,
|
|
32
|
+
} from './aggregate.ts'
|
|
33
|
+
import { groupMcpTools } from './grouping.ts'
|
|
34
|
+
import { probeEndpoint, probeJob, PROBE_KIND } from './probe.ts'
|
|
35
|
+
import { sanitizeText } from './sanitize.ts'
|
|
36
|
+
import type { McpServerStatus } from './upstream.ts'
|
|
37
|
+
import type { McpFiberPhase, McpPanelSnapshot, McpProbeView, ProbeStarted } from './wire.ts'
|
|
38
|
+
|
|
39
|
+
declare module '@deepseek-ai/cordis' {
|
|
40
|
+
interface Context {
|
|
41
|
+
/** Read-only MCP management snapshot service (this package). */
|
|
42
|
+
mcpPanel: McpPanelService
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Runtime mirror of the Cordis `FiberState` const enum (numeric cross-package
|
|
48
|
+
* const enums have no runtime import), projected to the wire phases.
|
|
49
|
+
*/
|
|
50
|
+
const FIBER_PHASE: Record<number, McpFiberPhase> = {
|
|
51
|
+
0: 'pending',
|
|
52
|
+
1: 'loading',
|
|
53
|
+
2: 'active',
|
|
54
|
+
3: 'failed',
|
|
55
|
+
4: null,
|
|
56
|
+
5: 'unloading',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The profile patch-layer filename the enable/disable suggestions name. */
|
|
60
|
+
const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
|
|
61
|
+
|
|
62
|
+
/** Label prefix written by the `mcp_probe` tool and the panel probe action. */
|
|
63
|
+
const PROBE_LABEL_PREFIX = 'mcp_probe '
|
|
64
|
+
|
|
65
|
+
/** Service-level runtime settings; the plugin passes its resolved config in. */
|
|
66
|
+
export interface McpPanelServiceConfig {
|
|
67
|
+
/** Per-probe timeout in milliseconds. */
|
|
68
|
+
probeTimeoutMs: number
|
|
69
|
+
/** Cap on probe records shown in the panel. */
|
|
70
|
+
maxProbes: number
|
|
71
|
+
/** Suggested panel refresh interval in ms (0 = on demand). */
|
|
72
|
+
refreshIntervalMs: number
|
|
73
|
+
/** Whether the passive probe loop runs. */
|
|
74
|
+
passiveProbeEnabled: boolean
|
|
75
|
+
/** Passive probe interval in milliseconds. */
|
|
76
|
+
passiveProbeIntervalMs: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Defaults for direct (non-Loader) service construction. */
|
|
80
|
+
const DEFAULT_SERVICE_CONFIG: McpPanelServiceConfig = {
|
|
81
|
+
probeTimeoutMs: 10_000,
|
|
82
|
+
maxProbes: 10,
|
|
83
|
+
refreshIntervalMs: 0,
|
|
84
|
+
passiveProbeEnabled: false,
|
|
85
|
+
passiveProbeIntervalMs: 60_000,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read-only MCP management snapshot service, exported over the `mcpPanel` Remote namespace. */
|
|
89
|
+
export class McpPanelService extends TypertRemoteService {
|
|
90
|
+
static inject = ['loader', 'tools']
|
|
91
|
+
|
|
92
|
+
/** Latest upstream payload per server namespace. */
|
|
93
|
+
private readonly statuses = new Map<string, McpServerStatus>()
|
|
94
|
+
/** Cumulative reconnect attempts observed per server namespace. */
|
|
95
|
+
private readonly reconnects = new Map<string, number>()
|
|
96
|
+
/** Epoch ms of the latest upstream event receipt per server namespace. */
|
|
97
|
+
private readonly observedAt = new Map<string, number>()
|
|
98
|
+
/** Latest passive-probe reachability per server namespace. */
|
|
99
|
+
private readonly probeStates = new Map<string, { state: 'reachable' | 'unreachable'; checkedAt: number }>()
|
|
100
|
+
/** Passive-probe loop guard: one sweep at a time. */
|
|
101
|
+
private passiveRunning = false
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param ctx - context carrying the loader and tool registry.
|
|
105
|
+
* @param config - resolved runtime settings; defaults apply for direct construction.
|
|
106
|
+
*/
|
|
107
|
+
constructor(ctx: Context, private readonly config: McpPanelServiceConfig = DEFAULT_SERVICE_CONFIG) {
|
|
108
|
+
super(ctx, 'mcpPanel')
|
|
109
|
+
if (config.passiveProbeEnabled) {
|
|
110
|
+
const timer = setInterval(() => { void this.runPassiveProbes() }, config.passiveProbeIntervalMs)
|
|
111
|
+
// An armed probe timer must never hold the process open on its own.
|
|
112
|
+
timer.unref?.()
|
|
113
|
+
ctx.effect(() => () => { clearInterval(timer) }, 'dsh-mcp-panel: passive probe loop')
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Record one upstream `mcp/status` payload (event or query-seed). A
|
|
119
|
+
* `connecting` payload with a positive attempt counts one reconnect.
|
|
120
|
+
*
|
|
121
|
+
* @param payload - post-transition status facts.
|
|
122
|
+
*/
|
|
123
|
+
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)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Assemble the current snapshot. Read-only: touches no configuration file
|
|
134
|
+
* and mutates no registry. Exported on the wire by the `mcpPanel/status`
|
|
135
|
+
* invocation descriptor in `./wire.ts` (registered through the package's
|
|
136
|
+
* `./typert` manifest) — no method decorator, so the built bundle stays
|
|
137
|
+
* plain ESM.
|
|
138
|
+
*
|
|
139
|
+
* @returns the wire snapshot (validated by the strict Typert codec on both faces).
|
|
140
|
+
*/
|
|
141
|
+
status(): McpPanelSnapshot {
|
|
142
|
+
const rows: McpLoaderRow[] = []
|
|
143
|
+
for (const entry of this.ctx.loader.entries()) {
|
|
144
|
+
if (entry.options.name !== MCP_CLIENT_MODULE) continue
|
|
145
|
+
rows.push({
|
|
146
|
+
// options.id is the user-written patch id (entry.id prefixes enclosing
|
|
147
|
+
// group ids such as `include:`), so patch suggestions match on reload.
|
|
148
|
+
entryId: entry.options.id,
|
|
149
|
+
disabled: entry.disabled,
|
|
150
|
+
fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state] ?? null,
|
|
151
|
+
config: entry.options.config,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
const schemas = this.ctx.tools.schemas()
|
|
155
|
+
const configuredNames = rows.map(row => serverNameOf(row.config, `entry:${row.entryId}`))
|
|
156
|
+
const groups = groupMcpTools(schemas, configuredNames)
|
|
157
|
+
return aggregateSnapshot({
|
|
158
|
+
rows,
|
|
159
|
+
groups,
|
|
160
|
+
facts: {
|
|
161
|
+
statuses: this.statuses,
|
|
162
|
+
reconnects: this.reconnects,
|
|
163
|
+
observedAt: this.observedAt,
|
|
164
|
+
probeStates: this.probeStates,
|
|
165
|
+
},
|
|
166
|
+
probes: this.probeViews(),
|
|
167
|
+
patchFile: this.patchFile(),
|
|
168
|
+
refreshIntervalMs: this.config.refreshIntervalMs,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Start a one-shot connectivity probe of one configured streamable-http
|
|
174
|
+
* server as an UNOWNED background job — panel-only, like the `mcp_probe`
|
|
175
|
+
* tool, but callable from the settings tab. Exported on the wire by the
|
|
176
|
+
* `mcpPanel/probe` invocation descriptor.
|
|
177
|
+
*
|
|
178
|
+
* @param serverName - configured namespace.
|
|
179
|
+
* @returns the started job id and where the result lands.
|
|
180
|
+
*/
|
|
181
|
+
probe(serverName: string): ProbeStarted {
|
|
182
|
+
const target = this.rawEndpoint(serverName)
|
|
183
|
+
if (target === undefined) {
|
|
184
|
+
throw new Error(`dsh-mcp-panel: "${serverName}" is not a configured streamable-http MCP server`)
|
|
185
|
+
}
|
|
186
|
+
const jobs = this.ctx.get('jobs')
|
|
187
|
+
if (jobs === undefined) {
|
|
188
|
+
throw new Error('dsh-mcp-panel: ctx.jobs is not composed — the panel probe action needs a background-job registry')
|
|
189
|
+
}
|
|
190
|
+
const jobId = jobs.start({
|
|
191
|
+
kind: PROBE_KIND,
|
|
192
|
+
label: `mcp_probe ${serverName}`,
|
|
193
|
+
// Unowned: no model completion notice, readable by the panel only.
|
|
194
|
+
run: () => probeJob(target.url, target.headers, this.config.probeTimeoutMs),
|
|
195
|
+
})
|
|
196
|
+
return {
|
|
197
|
+
jobId,
|
|
198
|
+
note: 'Probe results are panel-only: Settings → Plugins → MCP.',
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One passive-probe sweep over every configured streamable-http server. */
|
|
203
|
+
private async runPassiveProbes(): Promise<void> {
|
|
204
|
+
if (this.passiveRunning) return
|
|
205
|
+
this.passiveRunning = true
|
|
206
|
+
try {
|
|
207
|
+
for (const entry of this.ctx.loader.entries()) {
|
|
208
|
+
if (entry.options.name !== MCP_CLIENT_MODULE) continue
|
|
209
|
+
const serverName = serverNameOf(entry.options.config, `entry:${entry.options.id}`)
|
|
210
|
+
const target = this.rawEndpoint(serverName)
|
|
211
|
+
if (target === undefined) continue
|
|
212
|
+
const outcome = await probeEndpoint(target.url, target.headers, this.config.probeTimeoutMs, AbortSignal.timeout(this.config.probeTimeoutMs))
|
|
213
|
+
this.probeStates.set(serverName, { state: outcome.status === 'completed' ? 'reachable' : 'unreachable', checkedAt: Date.now() })
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
this.passiveRunning = false
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve one server's raw endpoint for the probe tool. Credentials stay
|
|
222
|
+
* inside this return value and are used for the request only — they never
|
|
223
|
+
* reach a snapshot, a log, or a display.
|
|
224
|
+
*
|
|
225
|
+
* @param serverName - configured namespace.
|
|
226
|
+
* @returns the raw URL + configured headers, or `undefined` when the server
|
|
227
|
+
* is not a configured streamable-http row.
|
|
228
|
+
*/
|
|
229
|
+
rawEndpoint(serverName: string): { url: string; headers: Record<string, string> } | undefined {
|
|
230
|
+
for (const entry of this.ctx.loader.entries()) {
|
|
231
|
+
if (entry.options.name !== MCP_CLIENT_MODULE) continue
|
|
232
|
+
const config = entry.options.config
|
|
233
|
+
if (serverNameOf(config, `entry:${entry.id}`) !== serverName) continue
|
|
234
|
+
if (typeof config !== 'object' || config === null || Array.isArray(config)) return undefined
|
|
235
|
+
const row = config as Record<string, unknown>
|
|
236
|
+
if (row['transport'] !== 'streamable-http') return undefined
|
|
237
|
+
const url = row['url']
|
|
238
|
+
if (typeof url !== 'string' || url === '') return undefined
|
|
239
|
+
const headersValue = row['headers']
|
|
240
|
+
const headers: Record<string, string> = {}
|
|
241
|
+
if (typeof headersValue === 'object' && headersValue !== null && !Array.isArray(headersValue)) {
|
|
242
|
+
for (const [name, value] of Object.entries(headersValue as Record<string, unknown>)) {
|
|
243
|
+
if (typeof value === 'string') headers[name] = value
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { url, headers }
|
|
247
|
+
}
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Unowned `mcp-probe` background jobs, newest first, sanitized for display. */
|
|
252
|
+
private probeViews(): McpProbeView[] {
|
|
253
|
+
const jobs = this.ctx.get('jobs')
|
|
254
|
+
if (jobs === undefined) return []
|
|
255
|
+
return jobs
|
|
256
|
+
.list()
|
|
257
|
+
.filter(job => job.kind === PROBE_KIND)
|
|
258
|
+
.map(job => ({
|
|
259
|
+
id: job.id,
|
|
260
|
+
serverName: job.label.startsWith(PROBE_LABEL_PREFIX) ? job.label.slice(PROBE_LABEL_PREFIX.length) : job.label,
|
|
261
|
+
status: job.status,
|
|
262
|
+
startedAt: job.startedAt,
|
|
263
|
+
finishedAt: job.finishedAt ?? null,
|
|
264
|
+
detail: job.detail === undefined ? null : sanitizeText(job.detail),
|
|
265
|
+
}))
|
|
266
|
+
.reverse()
|
|
267
|
+
.slice(0, this.config.maxProbes)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Absolute path of the profile patch layer the suggestions name, or null. */
|
|
271
|
+
private patchFile(): string | null {
|
|
272
|
+
const base = this.ctx.baseUrl
|
|
273
|
+
if (typeof base !== 'string' || base === '') return null
|
|
274
|
+
const dir = base.startsWith('file://') ? fileURLToPath(base) : base
|
|
275
|
+
return join(dir, PROFILE_PATCH_FILENAME)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export default McpPanelService
|