dsh-agent-plugins-market 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +29 -3
  2. package/README.zh.md +29 -3
  3. package/client/client.js +1388 -204
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +537 -71
  6. package/lib/application/details.js +39 -12
  7. package/lib/catalog/archive.js +341 -0
  8. package/lib/catalog/git.js +85 -12
  9. package/lib/catalog/legacy-root-migration.js +51 -0
  10. package/lib/catalog/lsp-spec.js +87 -0
  11. package/lib/catalog/manifests.js +84 -32
  12. package/lib/catalog/paths.js +65 -7
  13. package/lib/catalog/scan-pipeline.js +50 -0
  14. package/lib/catalog/scan-resolvers.js +402 -0
  15. package/lib/catalog/source-catalog.js +22 -8
  16. package/lib/catalog/suite-scanner.js +5 -176
  17. package/lib/catalog/surfaces.js +80 -30
  18. package/lib/contracts/lsp-status.js +2 -0
  19. package/lib/contracts/market.js +8 -1
  20. package/lib/index.js +118 -13
  21. package/lib/model/state.js +14 -1
  22. package/lib/model/types.js +21 -2
  23. package/lib/routes.js +88 -6
  24. package/lib/runtime/commands-mounts.js +6 -2
  25. package/lib/runtime/hooks-mounts.js +9 -6
  26. package/lib/runtime/lsp-direct-config.js +44 -0
  27. package/lib/runtime/lsp-mounts.js +236 -0
  28. package/lib/runtime/lsp-status.js +96 -0
  29. package/lib/runtime/mcp-backend.js +60 -0
  30. package/lib/runtime/mcp-client/bridge.js +126 -0
  31. package/lib/runtime/mcp-client/config.js +42 -0
  32. package/lib/runtime/mcp-client/connection.js +380 -0
  33. package/lib/runtime/mcp-client/host-seams.js +88 -0
  34. package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
  35. package/lib/runtime/mcp-client/oauth.js +379 -0
  36. package/lib/runtime/mcp-client/tools.js +432 -0
  37. package/lib/runtime/mcp-client/transport.js +103 -0
  38. package/lib/runtime/mcp-config.js +196 -54
  39. package/lib/runtime/mcp-credentials.js +20 -0
  40. package/lib/runtime/mcp-mounts.js +237 -27
  41. package/lib/runtime/mcp-overrides.js +64 -6
  42. package/lib/runtime/mcp-redaction.js +92 -0
  43. package/lib/runtime/mcp-status.js +76 -32
  44. package/lib/runtime/reconciler.js +35 -1
  45. package/lib/runtime/regions.js +56 -0
  46. package/lib/types/application/catalog.d.ts +191 -14
  47. package/lib/types/application/details.d.ts +1 -6
  48. package/lib/types/application/queries.d.ts +28 -1
  49. package/lib/types/catalog/archive.d.ts +48 -0
  50. package/lib/types/catalog/git.d.ts +27 -3
  51. package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
  52. package/lib/types/catalog/lsp-spec.d.ts +14 -0
  53. package/lib/types/catalog/manifests.d.ts +16 -1
  54. package/lib/types/catalog/paths.d.ts +23 -2
  55. package/lib/types/catalog/scan-pipeline.d.ts +76 -0
  56. package/lib/types/catalog/scan-resolvers.d.ts +81 -0
  57. package/lib/types/catalog/source-catalog.d.ts +5 -0
  58. package/lib/types/catalog/suite-scanner.d.ts +14 -7
  59. package/lib/types/catalog/surfaces.d.ts +3 -3
  60. package/lib/types/contracts/lsp-status.d.ts +53 -0
  61. package/lib/types/contracts/market.d.ts +55 -1
  62. package/lib/types/contracts/mcp-status.d.ts +14 -2
  63. package/lib/types/index.d.ts +24 -8
  64. package/lib/types/model/types.d.ts +52 -3
  65. package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
  66. package/lib/types/runtime/lsp-mounts.d.ts +89 -0
  67. package/lib/types/runtime/lsp-status.d.ts +30 -0
  68. package/lib/types/runtime/mcp-backend.d.ts +49 -0
  69. package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
  70. package/lib/types/runtime/mcp-client/config.d.ts +123 -0
  71. package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
  72. package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
  73. package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
  74. package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
  75. package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
  76. package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
  77. package/lib/types/runtime/mcp-config.d.ts +29 -12
  78. package/lib/types/runtime/mcp-credentials.d.ts +9 -0
  79. package/lib/types/runtime/mcp-mounts.d.ts +56 -6
  80. package/lib/types/runtime/mcp-overrides.d.ts +23 -4
  81. package/lib/types/runtime/mcp-redaction.d.ts +7 -0
  82. package/lib/types/runtime/mcp-status.d.ts +4 -2
  83. package/lib/types/runtime/reconciler.d.ts +22 -4
  84. package/lib/types/runtime/regions.d.ts +37 -0
  85. package/package.json +15 -11
  86. package/schemas/1.0.0/mcp.schema.json +22 -0
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Direct user configuration for LSP servers: a named table of `lspServers`
3
+ * entries stored under the plugin data root, merged at runtime alongside the
4
+ * suites' inline declarations.
5
+ *
6
+ * The file lives at `${dataRoot}/lsp-servers.json` and is the single source
7
+ * for the `direct` kind on the LSP status surface. Parsing reuses the same
8
+ * fail-closed `lsp-spec` rules as suite declarations; a broken file degrades
9
+ * to no direct servers plus a diagnostic, never a thrown discovery.
10
+ */
11
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
+ import { dirname, join } from 'node:path';
13
+ import { parseLspServers } from '../catalog/lsp-spec.js';
14
+ /** The direct LSP server configuration file path. */
15
+ export function lspServersPath(dataRoot) {
16
+ return join(dataRoot, 'lsp-servers.json');
17
+ }
18
+ /** Load the user-configured LSP servers; unreadable or invalid files yield an empty table. */
19
+ export async function loadLspServers(dataRoot) {
20
+ let raw;
21
+ try {
22
+ raw = JSON.parse(await readFile(lspServersPath(dataRoot), 'utf8'));
23
+ }
24
+ catch {
25
+ return { servers: {}, errors: [] };
26
+ }
27
+ const errors = [];
28
+ const table = raw?.['lspServers'];
29
+ const servers = parseLspServers(table, errors);
30
+ return { servers, errors: errors.map(error => `lsp-servers.json: ${error}`) };
31
+ }
32
+ /** Validate and persist the user-configured LSP servers; returns normalized specs. */
33
+ export async function saveLspServers(dataRoot, raw) {
34
+ const errors = [];
35
+ const table = raw?.['lspServers'];
36
+ const servers = parseLspServers(table, errors);
37
+ if (errors.length > 0) {
38
+ throw new Error(`invalid lspServers: ${errors[0]}`);
39
+ }
40
+ const path = lspServersPath(dataRoot);
41
+ await mkdir(dirname(path), { recursive: true });
42
+ await writeFile(path, `${JSON.stringify({ lspServers: servers }, null, 2)}\n`, 'utf8');
43
+ return { servers };
44
+ }
@@ -0,0 +1,236 @@
1
+ import { DIRECT_LSP_SUITE_ID } from './lsp-status.js';
2
+ import { qualifiedSuiteId } from '../catalog/paths.js';
3
+ /**
4
+ * Bounded retry schedule for a failed mount/unmount, mirroring the MCP
5
+ * mounts: a permanently broken server must stop consuming attempt budget,
6
+ * and the schedule resets with every reconcile pass.
7
+ */
8
+ const RETRY_SCHEDULE_MS = [1_500, 5_000, 15_000, 45_000, 120_000];
9
+ const MAX_RETRY_ATTEMPTS = RETRY_SCHEDULE_MS.length;
10
+ /** Build one dsh-lsp-stdio Config.servers entry from a normalized spec. */
11
+ export function toLspServerConfig(spec) {
12
+ return {
13
+ command: spec.command,
14
+ args: spec.args,
15
+ extensionToLanguage: spec.extensionToLanguage,
16
+ ...(spec.env === undefined ? {} : { env: spec.env }),
17
+ ...(spec.initializationOptions === undefined ? {} : { initializationOptions: spec.initializationOptions }),
18
+ ...(spec.configuration === undefined ? {} : { configuration: spec.configuration })
19
+ };
20
+ }
21
+ /** Import spec kept as a string literal so the optional dependency stays dynamic. */
22
+ const LSP_STDIO_IMPORT = '@deepseek-ai/dsh-lsp-stdio';
23
+ export class LspMountRegistry {
24
+ ctx;
25
+ live = new Map();
26
+ /** Serialize mount and unmount passes so a disable cannot race an in-flight spawn. */
27
+ reconcileQueue = Promise.resolve();
28
+ /** Pending retry timers keyed by mount key, so a teardown can cancel them. */
29
+ retries = new Map();
30
+ /** Attempt count per mount key; reset whenever a retry succeeds. */
31
+ attempts = new Map();
32
+ /** Snapshot of the last reconciled suite set, replayed by retry passes. */
33
+ lastEnabled = [];
34
+ /** Host module loader; overridable for tests. */
35
+ loadHost;
36
+ /** Latest diagnostic per suite id; cleared when its suite mounts or leaves the wanted set. */
37
+ lastDiagnostics = new Map();
38
+ /** Direct (user-configured) server provider; defaults to none. */
39
+ directProvider = async () => ({});
40
+ constructor(ctx, loadHost) {
41
+ this.ctx = ctx;
42
+ this.loadHost =
43
+ loadHost ??
44
+ (async () => {
45
+ try {
46
+ return (await import(/* @vite-ignore */ LSP_STDIO_IMPORT));
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ });
52
+ }
53
+ /** Queue one reconciliation behind any in-flight mount/unmount pass. */
54
+ reconcile(enabledSuites) {
55
+ const run = this.reconcileQueue.then(() => this.reconcileNow(enabledSuites));
56
+ this.reconcileQueue = run.then(() => undefined, () => undefined);
57
+ return run;
58
+ }
59
+ /** Mount/unmount LSP servers to match the enabled suites plus direct config exactly. */
60
+ async reconcileNow(enabledSuites) {
61
+ this.lastEnabled = [...enabledSuites];
62
+ const active = enabledSuites.filter(suite => suite.activeSurfaces?.lsp !== false);
63
+ const wanted = new Map();
64
+ const diagnostics = [];
65
+ for (const suite of active) {
66
+ const servers = Object.values(suite.lsp?.servers ?? {});
67
+ if (servers.length === 0)
68
+ continue;
69
+ // The wanted key is the qualified suite id: bare suite ids are unique
70
+ // per source only, so two sources shipping the same suite name would
71
+ // otherwise shadow each other's mount and diagnostics.
72
+ const key = qualifiedSuiteId(suite.sourceId, suite.id);
73
+ const config = {};
74
+ for (const spec of servers)
75
+ config[`${key}/${spec.key}`] = toLspServerConfig(spec);
76
+ wanted.set(key, { suite, config });
77
+ }
78
+ // Direct user-configured servers ride the same mount path under the
79
+ // sentinel suite id, so their lifecycle (retries, diagnostics, disposal)
80
+ // is identical to a suite's.
81
+ const direct = await this.directProvider();
82
+ const directKeys = Object.keys(direct);
83
+ if (directKeys.length > 0) {
84
+ const config = {};
85
+ for (const [key, spec] of Object.entries(direct))
86
+ config[`${DIRECT_LSP_SUITE_ID}/${key}`] = toLspServerConfig(spec);
87
+ wanted.set(DIRECT_LSP_SUITE_ID, {
88
+ suite: {
89
+ sourceId: '',
90
+ id: DIRECT_LSP_SUITE_ID,
91
+ root: '',
92
+ manifest: { layout: 'claude-code', path: '', id: DIRECT_LSP_SUITE_ID, name: DIRECT_LSP_SUITE_ID },
93
+ skills: [],
94
+ surfaces: { skills: 0, mcp: 0, hooks: 0, commands: 0, agents: 0, lsp: directKeys.length },
95
+ dimension: 'user',
96
+ enabled: true,
97
+ errors: []
98
+ },
99
+ config
100
+ });
101
+ }
102
+ for (const [key, live] of [...this.live]) {
103
+ if (!wanted.has(key)) {
104
+ const reason = await this.unmount(key, live);
105
+ this.lastDiagnostics.delete(live.suiteId);
106
+ if (reason !== undefined)
107
+ diagnostics.push({ suiteId: live.suiteId, serverKey: live.serverKeys.join(','), reason, code: 'unmount-failed' });
108
+ }
109
+ }
110
+ for (const [key, entry] of wanted) {
111
+ if (this.live.has(key)) {
112
+ this.lastDiagnostics.delete(key);
113
+ continue;
114
+ }
115
+ const failure = await this.mountWith(key, entry.suite, entry.config);
116
+ if (failure !== undefined) {
117
+ diagnostics.push(failure);
118
+ this.lastDiagnostics.set(key, failure);
119
+ this.scheduleRetry(key, failure.code === 'host-missing' ? undefined : failure);
120
+ }
121
+ else {
122
+ this.lastDiagnostics.delete(key);
123
+ }
124
+ }
125
+ return diagnostics;
126
+ }
127
+ /** Install the direct (user-configured) server provider used at reconcile time. */
128
+ setDirectProvider(provider) {
129
+ this.directProvider = provider;
130
+ }
131
+ /** The latest mount diagnostic per suite, for the LSP status surface. */
132
+ diagnosticsSnapshot() {
133
+ return new Map(this.lastDiagnostics);
134
+ }
135
+ /** Whether at least one suite mount is live. */
136
+ hasLiveMounts() {
137
+ return this.live.size > 0;
138
+ }
139
+ /** Mount one suite's full server table as a single `dsh-lsp-stdio` instance. */
140
+ async mountWith(key, suite, servers) {
141
+ const hostKeys = Object.keys(servers).join(',');
142
+ const module = await this.loadHost();
143
+ if (module === undefined) {
144
+ return { suiteId: suite.id, serverKey: hostKeys, reason: HOST_MISSING_REASON, code: 'host-missing' };
145
+ }
146
+ const plugin = module.default ?? module;
147
+ const mountCtx = this.ctx;
148
+ if (typeof mountCtx.plugin !== 'function') {
149
+ return { suiteId: suite.id, serverKey: hostKeys, reason: 'the host context does not support dynamic plugin mounting', code: 'host-missing' };
150
+ }
151
+ let handle;
152
+ try {
153
+ handle = mountCtx.plugin(plugin, { servers });
154
+ await handle.await();
155
+ }
156
+ catch (error) {
157
+ // A failed startup can leave a half-mounted handle behind; drop it so
158
+ // no orphan child process survives, then report the real signal.
159
+ if (handle !== undefined) {
160
+ try {
161
+ await handle.dispose();
162
+ }
163
+ catch {
164
+ // Ignore teardown errors: the startup failure is the real signal.
165
+ }
166
+ }
167
+ const message = error instanceof Error ? error.message : String(error);
168
+ const conflict = /already handled by another LSP provider|already registered/.test(message);
169
+ return {
170
+ suiteId: suite.id,
171
+ serverKey: hostKeys,
172
+ reason: `mount failed: ${message}`,
173
+ code: conflict ? 'seam-conflict' : 'mount-failed'
174
+ };
175
+ }
176
+ this.live.set(key, { suiteId: suite.id, serverKeys: Object.keys(servers), disposer: () => handle.dispose() });
177
+ return undefined;
178
+ }
179
+ async unmount(key, live) {
180
+ try {
181
+ await live.disposer();
182
+ this.live.delete(key);
183
+ return undefined;
184
+ }
185
+ catch (error) {
186
+ const reason = `unmount failed: ${error instanceof Error ? error.message : String(error)}`;
187
+ this.ctx.logger?.warn?.(`[dsh-agent-plugins-market] ${reason} (${live.suiteId})`);
188
+ return reason;
189
+ }
190
+ }
191
+ /**
192
+ * Schedule a delayed re-attempt for a mount that still is not live. A
193
+ * `host-missing` failure passes no diagnostic and is never retried; the
194
+ * schedule is bounded and resets with every reconcile pass.
195
+ */
196
+ scheduleRetry(key, failure) {
197
+ const pending = this.retries.get(key);
198
+ if (pending !== undefined)
199
+ clearTimeout(pending);
200
+ this.retries.delete(key);
201
+ if (failure === undefined || failure.code === 'host-missing') {
202
+ this.attempts.delete(key);
203
+ return;
204
+ }
205
+ const attempt = (this.attempts.get(key) ?? 0) + 1;
206
+ this.attempts.set(key, attempt);
207
+ if (attempt > MAX_RETRY_ATTEMPTS) {
208
+ this.ctx.logger?.warn?.(`[dsh-agent-plugins-market] ${failure.suiteId}: giving up after ${MAX_RETRY_ATTEMPTS} attempts — ${failure.reason}`);
209
+ return;
210
+ }
211
+ const delay = RETRY_SCHEDULE_MS[attempt - 1] ?? RETRY_SCHEDULE_MS[RETRY_SCHEDULE_MS.length - 1];
212
+ const timer = setTimeout(() => {
213
+ this.retries.delete(key);
214
+ // Re-run against the last known suite set: a retry must not resurrect
215
+ // servers of a suite that has since been disabled or uninstalled.
216
+ void this.reconcile(this.lastEnabled).catch(() => { });
217
+ }, delay);
218
+ timer.unref?.();
219
+ this.retries.set(key, timer);
220
+ }
221
+ /** Dispose every live mount after queued reconciliation passes settle. */
222
+ async disposeAll() {
223
+ const run = this.reconcileQueue.then(async () => {
224
+ for (const [key, live] of [...this.live]) {
225
+ await this.unmount(key, live);
226
+ }
227
+ });
228
+ this.reconcileQueue = run.then(() => undefined, () => undefined);
229
+ await run;
230
+ for (const timer of this.retries.values())
231
+ clearTimeout(timer);
232
+ this.retries.clear();
233
+ this.attempts.clear();
234
+ }
235
+ }
236
+ const HOST_MISSING_REASON = 'the @deepseek-ai/dsh-lsp-stdio package is not installed in this profile; upgrade to a DSH build carrying the LSP packages (@deepseek-ai/dsh-lsp, dsh-lsp-stdio, dsh-tool-lsp) and mount tool-lsp in the profile to expose the `lsp` tool';
@@ -0,0 +1,96 @@
1
+ import { qualifiedSuiteId } from '../catalog/paths.js';
2
+ /** Sentinel suite id for user-configured (non-suite) LSP servers. */
3
+ export const DIRECT_LSP_SUITE_ID = 'direct';
4
+ /**
5
+ * Derive one row's state from its suite's diagnostic and the live-mount flag.
6
+ * Shared by suite rows and direct rows so both kinds report identically.
7
+ */
8
+ function deriveState(disabled, diagnostic, anyLive) {
9
+ if (disabled)
10
+ return { state: 'disabled' };
11
+ if (diagnostic?.code === 'host-missing')
12
+ return { state: 'host-missing', reason: diagnostic.reason };
13
+ if (diagnostic?.code === 'seam-conflict')
14
+ return { state: 'conflict', reason: diagnostic.reason };
15
+ if (diagnostic?.code === 'mount-failed' || diagnostic?.code === 'unmount-failed') {
16
+ return { state: 'failed', reason: diagnostic.reason, retryable: true };
17
+ }
18
+ if (diagnostic !== undefined)
19
+ return { state: 'failed', reason: diagnostic.reason };
20
+ if (anyLive)
21
+ return { state: 'mounted' };
22
+ // A declaration with neither a diagnostic nor a live mount is mid-flight:
23
+ // the reconciler runs after the host finishes discovery, so the first
24
+ // panel reads during startup land here. Report `starting` — the panel
25
+ // polls while any row is starting — instead of the misleading
26
+ // host-missing verdict, which is reserved for an actual import failure.
27
+ return { state: 'starting' };
28
+ }
29
+ /** Build the status payload from declarations, direct configuration, and mount diagnostics. */
30
+ export function buildLspStatus(suites, registry, direct = { servers: {}, errors: [] }) {
31
+ const diagnostics = registry.diagnosticsSnapshot();
32
+ const anyLive = registry.hasLiveMounts();
33
+ const entries = [];
34
+ const mountedSuiteIds = new Set();
35
+ for (const suite of suites) {
36
+ // Operational inventory, mirroring mcp-status: only installed+enabled
37
+ // suites' declarations appear. The diagnostic key is the qualified suite
38
+ // id, matching the mount registry's wanted key exactly.
39
+ if (suite.lsp === undefined || suite.installedAt === undefined || !suite.enabled)
40
+ continue;
41
+ const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
42
+ const disabled = suite.activeSurfaces?.lsp === false;
43
+ const diagnostic = diagnostics.get(suiteKey);
44
+ const { state, reason, retryable } = deriveState(disabled, diagnostic, anyLive);
45
+ if (state === 'mounted')
46
+ mountedSuiteIds.add(suiteKey);
47
+ for (const spec of Object.values(suite.lsp.servers)) {
48
+ entries.push({
49
+ id: `${suiteKey}/${spec.key}`,
50
+ serverKey: spec.key,
51
+ suiteId: suiteKey,
52
+ suiteName: suite.manifest.name,
53
+ sourceId: suite.sourceId,
54
+ kind: 'plugin',
55
+ command: spec.command,
56
+ args: spec.args,
57
+ extensions: spec.extensionToLanguage,
58
+ state,
59
+ ...(reason === undefined ? {} : { reason }),
60
+ ...(retryable === undefined ? {} : { retryable })
61
+ });
62
+ }
63
+ }
64
+ // Direct user-configured rows: no install state or surface toggle of their
65
+ // own — configured means on; the mount diagnostic decides the state.
66
+ const directDiagnostic = diagnostics.get(DIRECT_LSP_SUITE_ID);
67
+ const { state: directState, reason: directReason, retryable: directRetryable } = deriveState(false, directDiagnostic, anyLive);
68
+ if (directState === 'mounted')
69
+ mountedSuiteIds.add(DIRECT_LSP_SUITE_ID);
70
+ for (const spec of Object.values(direct.servers)) {
71
+ entries.push({
72
+ id: `${DIRECT_LSP_SUITE_ID}/${spec.key}`,
73
+ serverKey: spec.key,
74
+ suiteId: DIRECT_LSP_SUITE_ID,
75
+ suiteName: spec.key,
76
+ sourceId: '',
77
+ kind: 'direct',
78
+ command: spec.command,
79
+ args: spec.args,
80
+ extensions: spec.extensionToLanguage,
81
+ state: directState,
82
+ ...(directReason === undefined ? {} : { reason: directReason }),
83
+ ...(directRetryable === undefined ? {} : { retryable: directRetryable })
84
+ });
85
+ }
86
+ const mounted = entries.filter(entry => entry.state === 'mounted').length;
87
+ const failed = entries.filter(entry => entry.state === 'failed').length;
88
+ const disabled = entries.filter(entry => entry.state === 'disabled').length;
89
+ const blocked = entries.filter(entry => entry.state === 'host-missing' || entry.state === 'conflict').length;
90
+ return {
91
+ entries,
92
+ observedAt: new Date().toISOString(),
93
+ totals: { all: entries.length, mounted, failed, blocked, disabled },
94
+ hostMissing: entries.length > 0 && mountedSuiteIds.size === 0 && entries.every(entry => entry.state === 'host-missing')
95
+ };
96
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * MCP backend selection: whether suite MCP servers mount through the
3
+ * market's self-built bridge (default — stdio, Streamable HTTP with OAuth,
4
+ * and legacy SSE) or through the host's `@deepseek-ai/dsh-mcp-client`
5
+ * (compatibility mode: no OAuth, no SSE, but the host-native implementation).
6
+ *
7
+ * The choice is a host settings namespace (`dsh-agent-plugins-market`,
8
+ * `mcpEnhanced: boolean`, default true) registered by the plugin's node half:
9
+ * the registration is what makes the host 插件配置 tab serve our card, the
10
+ * client card binds it for state, and the node half watches it to remount
11
+ * servers when the switch flips. `readMcpBackend` remains only as the
12
+ * one-time migration from the earlier data-root `settings.json` choice.
13
+ *
14
+ * @module runtime/mcp-backend
15
+ */
16
+ import { readFile } from 'node:fs/promises';
17
+ import { createRequire } from 'node:module';
18
+ import { join } from 'node:path';
19
+ import z from '@deepseek-ai/schemastery';
20
+ export const MCP_BACKENDS = ['builtin', 'host'];
21
+ /** Settings namespace this plugin registers; the key the plugin-config tab pairs our card by. */
22
+ export const MCP_SETTINGS_NAMESPACE = 'dsh-agent-plugins-market';
23
+ /** Schema of the market settings namespace: the MCP enhancement switch and the download region. */
24
+ export const MarketSettingsSchema = z.object({
25
+ /** ON (default) = the built-in bridge with OAuth and SSE; OFF = host client compat mode. */
26
+ mcpEnhanced: z.boolean().default(true),
27
+ /** Download region for GitHub acquisition; `auto` follows the interface language. */
28
+ downloadRegion: z.union([z.const('auto'), z.const('global'), z.const('china')]).default('auto')
29
+ });
30
+ /** Path of the legacy persisted settings file under the plugin data root. */
31
+ export function marketSettingsPath(dataRoot) {
32
+ return join(dataRoot, 'settings.json');
33
+ }
34
+ /** Read the legacy persisted backend; absent or invalid values read as the default. */
35
+ export async function readMcpBackend(dataRoot) {
36
+ try {
37
+ const parsed = JSON.parse(await readFile(marketSettingsPath(dataRoot), 'utf8'));
38
+ return parsed.mcpBackend === 'host' ? 'host' : 'builtin';
39
+ }
40
+ catch {
41
+ return 'builtin';
42
+ }
43
+ }
44
+ /**
45
+ * Probe whether the host's `dsh-mcp-client` is resolvable from this plugin's
46
+ * module context, and at which version. Best effort: any failure reads as
47
+ * "unavailable", which the mount path surfaces as a per-server diagnostic
48
+ * when the host backend is selected.
49
+ */
50
+ export async function probeHostMcpClient() {
51
+ try {
52
+ const require = createRequire(import.meta.url);
53
+ const manifestPath = require.resolve('@deepseek-ai/dsh-mcp-client/package.json');
54
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
55
+ return { available: true, ...(manifest.version === undefined ? {} : { version: manifest.version }) };
56
+ }
57
+ catch {
58
+ return { available: false };
59
+ }
60
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The market's self-built MCP client bridge plugin: connects to one external
3
+ * MCP server and registers its tools on the harness ToolRuntime under
4
+ * server-qualified public names (`mcp__<serverName>__<rawName>`). One plugin
5
+ * instance per server; the mount registry loads instances through
6
+ * `ctx.plugin` exactly like the host's own client, so reconcile, retry,
7
+ * rollback, and HMR semantics are unchanged.
8
+ *
9
+ * Namespace plugin (named exports, no default export). Lifecycle is
10
+ * effect-scoped: disposal disconnects from the server, unregisters all tools,
11
+ * and releases the `serverName` namespace reservation. HMR hot-swaps by
12
+ * disposing the old instance and creating a new one; identical `serverName`
13
+ * reproduces identical public tool names.
14
+ *
15
+ * This replaces the runtime dependency on the host's `dsh-mcp-client`
16
+ * package: stdio, Streamable HTTP (with OAuth 2.1), and legacy SSE all run
17
+ * inside this plugin from the market's own `@modelcontextprotocol/sdk`
18
+ * dependency.
19
+ *
20
+ * @module runtime/mcp-client/bridge
21
+ */
22
+ import { resolveReconnectPolicy, startConnection } from './connection.js';
23
+ import { validateConfig } from './config.js';
24
+ /** Cordis plugin name used by loader diagnostics. */
25
+ export const name = 'market-mcp-client';
26
+ /** Services required by this plugin. */
27
+ export const inject = ['tools'];
28
+ /**
29
+ * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps
30
+ * in one process — tests — must not see each other's names). A duplicate
31
+ * namespace is a configuration error surfaced at plugin load, never silent
32
+ * shadowing. The mount registry deduplicates too; this is the last line of
33
+ * defense for direct programmatic loads.
34
+ */
35
+ const activeServerNames = new WeakMap();
36
+ /** Tolerantly read an optional service from the cordis context. */
37
+ function optionalService(ctx, serviceName) {
38
+ try {
39
+ const reader = ctx;
40
+ const service = reader.get?.(serviceName);
41
+ // A missing service either reads as undefined or throws (cordis strict
42
+ // mode); both mean "not mounted".
43
+ return service === null ? undefined : service;
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ /** Adapt the cordis context onto the structural host the bridge modules use. */
50
+ function toToolHost(ctx) {
51
+ const logger = {
52
+ error: (message) => {
53
+ ctx.logger?.error?.(message);
54
+ },
55
+ warn: (message) => {
56
+ ctx.logger?.warn?.(message);
57
+ },
58
+ info: (message) => {
59
+ ctx.logger?.info?.(message);
60
+ }
61
+ };
62
+ const registry = ctx;
63
+ return {
64
+ logger,
65
+ tools: {
66
+ register(definition) {
67
+ const register = registry.tools?.register;
68
+ if (typeof register !== 'function') {
69
+ throw new Error('the host context does not expose a tool registry — the "tools" service is required');
70
+ }
71
+ return register.call(registry.tools, definition);
72
+ }
73
+ },
74
+ getService: serviceName => optionalService(ctx, serviceName)
75
+ };
76
+ }
77
+ /**
78
+ * Connect one MCP server and publish its initial tool generation before
79
+ * activation. Remains explicitly `async`: Cordis treats a prototype-bearing
80
+ * ordinary function as a constructor, whose returned Promise is not startup
81
+ * work.
82
+ * @param ctx - plugin context carrying the tool registry.
83
+ * @param config - resolved transport and server namespace configuration.
84
+ * @returns startup readiness after connection and initial tool discovery settle.
85
+ */
86
+ export async function apply(ctx, config) {
87
+ // Fail loud at load: programmatic construction bypasses any schema layer,
88
+ // so every invariant is re-judged here before any effect registers.
89
+ validateConfig(config);
90
+ const reconnect = resolveReconnectPolicy(config.reconnect, `mcp-client(${config.serverName}): reconnect`);
91
+ // Reserve the namespace next: a duplicate `serverName` fails THIS instance
92
+ // at load with an actionable error and leaves the earlier instance intact.
93
+ ctx.effect(() => {
94
+ let names = activeServerNames.get(ctx.root);
95
+ if (!names) {
96
+ names = new Set();
97
+ activeServerNames.set(ctx.root, names);
98
+ }
99
+ if (names.has(config.serverName)) {
100
+ throw new Error(`market-mcp-client: serverName "${config.serverName}" is already in use by another bridge instance — pick a unique serverName`);
101
+ }
102
+ names.add(config.serverName);
103
+ return () => void names.delete(config.serverName);
104
+ }, 'market-mcp-client.serverName');
105
+ // The supervisor owns the client/transport generations, the reconnect
106
+ // loop, and the live tool registrations; disposal stops reconnection,
107
+ // quiesces in-flight work, and unregisters the current generation.
108
+ // Optional service: the credential store receives OAuth tokens when
109
+ // mounted; absence (no credentials plugin) is the supported
110
+ // no-persistence configuration.
111
+ const host = toToolHost(ctx);
112
+ const credentials = optionalService(ctx, 'credentials');
113
+ const connection = startConnection(host, config, reconnect, credentials);
114
+ ctx.effect(() => {
115
+ return () => connection.dispose();
116
+ }, 'market-mcp-client.connection');
117
+ // Block plugin activation on the initial connection + tool discovery so
118
+ // Cordis consumers observe the tools immediately after the fiber activates.
119
+ // When failOnStartupError is true, a failed initial attempt rejects the
120
+ // fiber (Cordis rolls it back); otherwise the error is logged and the
121
+ // supervisor enters its reconnect loop.
122
+ const outcome = await connection.ready;
123
+ if (outcome.error !== undefined && config.failOnStartupError) {
124
+ throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error });
125
+ }
126
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Configuration types for the market's self-built MCP client bridge. One
3
+ * bridge instance connects to one MCP server; mounts are built
4
+ * programmatically by `mcp-config.ts`, so there is no Schemastery schema —
5
+ * every field the supervisor relies on is re-judged at load
6
+ * (`resolveReconnectPolicy`, serverName pattern) and misconfiguration fails
7
+ * that instance loudly instead of being normalized silently.
8
+ *
9
+ * @module runtime/mcp-client/config
10
+ */
11
+ export { RECONNECT_DEFAULTS } from './connection.js';
12
+ /** Default timeout for individual MCP tool calls (ms). */
13
+ export const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000;
14
+ /** Valid `serverName`, kept below the public tool-name budget. */
15
+ export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
16
+ /**
17
+ * Validate one programmatically-built config. Programmatic construction
18
+ * bypasses any schema layer, so every invariant the bridge relies on is
19
+ * re-judged here — a misconfiguration rejects the mount with an actionable
20
+ * error before any connection is attempted.
21
+ * @param config - raw bridge config built by `mcp-config.ts`.
22
+ * @returns the same config, narrowed to the validated shape.
23
+ */
24
+ export function validateConfig(config) {
25
+ const fields = config;
26
+ if (!SERVER_NAME_PATTERN.test(config.serverName)) {
27
+ throw new Error(`bridge config serverName "${config.serverName}" must match ${String(SERVER_NAME_PATTERN)}`);
28
+ }
29
+ if (!Number.isFinite(fields.toolCallTimeoutMs) || fields.toolCallTimeoutMs <= 0) {
30
+ throw new Error(`bridge config toolCallTimeoutMs must be a positive finite number`);
31
+ }
32
+ if (typeof fields.failOnStartupError !== 'boolean') {
33
+ throw new Error('bridge config failOnStartupError must be a boolean');
34
+ }
35
+ if (config.transport === 'stdio' && (typeof config.command !== 'string' || config.command === '')) {
36
+ throw new Error('bridge stdio config requires a command');
37
+ }
38
+ if (config.transport !== 'stdio' && (typeof config.url !== 'string' || config.url === '')) {
39
+ throw new Error(`bridge ${config.transport} config requires a url`);
40
+ }
41
+ return config;
42
+ }