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.
- package/README.md +29 -3
- package/README.zh.md +29 -3
- package/client/client.js +1388 -204
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +537 -71
- package/lib/application/details.js +39 -12
- package/lib/catalog/archive.js +341 -0
- package/lib/catalog/git.js +85 -12
- package/lib/catalog/legacy-root-migration.js +51 -0
- package/lib/catalog/lsp-spec.js +87 -0
- package/lib/catalog/manifests.js +84 -32
- package/lib/catalog/paths.js +65 -7
- package/lib/catalog/scan-pipeline.js +50 -0
- package/lib/catalog/scan-resolvers.js +402 -0
- package/lib/catalog/source-catalog.js +22 -8
- package/lib/catalog/suite-scanner.js +5 -176
- package/lib/catalog/surfaces.js +80 -30
- package/lib/contracts/lsp-status.js +2 -0
- package/lib/contracts/market.js +8 -1
- package/lib/index.js +118 -13
- package/lib/model/state.js +14 -1
- package/lib/model/types.js +21 -2
- package/lib/routes.js +88 -6
- package/lib/runtime/commands-mounts.js +6 -2
- package/lib/runtime/hooks-mounts.js +9 -6
- package/lib/runtime/lsp-direct-config.js +44 -0
- package/lib/runtime/lsp-mounts.js +236 -0
- package/lib/runtime/lsp-status.js +96 -0
- package/lib/runtime/mcp-backend.js +60 -0
- package/lib/runtime/mcp-client/bridge.js +126 -0
- package/lib/runtime/mcp-client/config.js +42 -0
- package/lib/runtime/mcp-client/connection.js +380 -0
- package/lib/runtime/mcp-client/host-seams.js +88 -0
- package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
- package/lib/runtime/mcp-client/oauth.js +379 -0
- package/lib/runtime/mcp-client/tools.js +432 -0
- package/lib/runtime/mcp-client/transport.js +103 -0
- package/lib/runtime/mcp-config.js +196 -54
- package/lib/runtime/mcp-credentials.js +20 -0
- package/lib/runtime/mcp-mounts.js +237 -27
- package/lib/runtime/mcp-overrides.js +64 -6
- package/lib/runtime/mcp-redaction.js +92 -0
- package/lib/runtime/mcp-status.js +76 -32
- package/lib/runtime/reconciler.js +35 -1
- package/lib/runtime/regions.js +56 -0
- package/lib/types/application/catalog.d.ts +191 -14
- package/lib/types/application/details.d.ts +1 -6
- package/lib/types/application/queries.d.ts +28 -1
- package/lib/types/catalog/archive.d.ts +48 -0
- package/lib/types/catalog/git.d.ts +27 -3
- package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
- package/lib/types/catalog/lsp-spec.d.ts +14 -0
- package/lib/types/catalog/manifests.d.ts +16 -1
- package/lib/types/catalog/paths.d.ts +23 -2
- package/lib/types/catalog/scan-pipeline.d.ts +76 -0
- package/lib/types/catalog/scan-resolvers.d.ts +81 -0
- package/lib/types/catalog/source-catalog.d.ts +5 -0
- package/lib/types/catalog/suite-scanner.d.ts +14 -7
- package/lib/types/catalog/surfaces.d.ts +3 -3
- package/lib/types/contracts/lsp-status.d.ts +53 -0
- package/lib/types/contracts/market.d.ts +55 -1
- package/lib/types/contracts/mcp-status.d.ts +14 -2
- package/lib/types/index.d.ts +24 -8
- package/lib/types/model/types.d.ts +52 -3
- package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
- package/lib/types/runtime/lsp-mounts.d.ts +89 -0
- package/lib/types/runtime/lsp-status.d.ts +30 -0
- package/lib/types/runtime/mcp-backend.d.ts +49 -0
- package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
- package/lib/types/runtime/mcp-client/config.d.ts +123 -0
- package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
- package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
- package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
- package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
- package/lib/types/runtime/mcp-config.d.ts +29 -12
- package/lib/types/runtime/mcp-credentials.d.ts +9 -0
- package/lib/types/runtime/mcp-mounts.d.ts +56 -6
- package/lib/types/runtime/mcp-overrides.d.ts +23 -4
- package/lib/types/runtime/mcp-redaction.d.ts +7 -0
- package/lib/types/runtime/mcp-status.d.ts +4 -2
- package/lib/types/runtime/reconciler.d.ts +22 -4
- package/lib/types/runtime/regions.d.ts +37 -0
- package/package.json +15 -11
- package/schemas/1.0.0/mcp.schema.json +22 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection supervisor: owns the MCP client/transport generations for one
|
|
3
|
+
* bridge instance, keeps the harness tool registry in sync with the live
|
|
4
|
+
* generation, and — when the connection drops — restarts the configured
|
|
5
|
+
* server with bounded exponential backoff.
|
|
6
|
+
*
|
|
7
|
+
* One outage shares one attempt budget (`maxAttempts` consecutive failed
|
|
8
|
+
* attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A
|
|
9
|
+
* connection that stays up past the stability window closes the outage, so
|
|
10
|
+
* the next disconnect starts a fresh budget while a crash-looping server —
|
|
11
|
+
* even one whose connects briefly succeed — still exhausts the cap instead of
|
|
12
|
+
* restarting forever. Exhaustion unregisters the server's tools and stops;
|
|
13
|
+
* disposal (including HMR) is the only way back from that state.
|
|
14
|
+
*
|
|
15
|
+
* Ported from the harness `dsh-mcp-client` supervisor (including the OAuth
|
|
16
|
+
* browser-leg hold: a failed initialize during a pending browser
|
|
17
|
+
* authorization holds the generation open — up to the loopback callback
|
|
18
|
+
* budget — instead of tearing down the authorization the user is mid-way
|
|
19
|
+
* through approving).
|
|
20
|
+
*
|
|
21
|
+
* @module runtime/mcp-client/connection
|
|
22
|
+
*/
|
|
23
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
24
|
+
import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
25
|
+
import { MAX_TIMER_DELAY_MS } from './host-seams.js';
|
|
26
|
+
import { createTransport } from './transport.js';
|
|
27
|
+
import { syncTools } from './tools.js';
|
|
28
|
+
/** Defaults shared by the config and {@link resolveReconnectPolicy}. */
|
|
29
|
+
export const RECONNECT_DEFAULTS = Object.freeze({
|
|
30
|
+
enabled: true,
|
|
31
|
+
initialDelayMs: 500,
|
|
32
|
+
maxDelayMs: 30_000,
|
|
33
|
+
maxAttempts: 10
|
|
34
|
+
});
|
|
35
|
+
// The SDK's stdio transport owns two two-second termination grace periods.
|
|
36
|
+
// Keep one additional second for the process-close event that proves the old
|
|
37
|
+
// generation is gone; timing out fails closed instead of overlapping children.
|
|
38
|
+
const GENERATION_CLOSE_TIMEOUT_MS = 5_000;
|
|
39
|
+
/**
|
|
40
|
+
* The one explicit resolve step from raw reconnect config to the policy the
|
|
41
|
+
* supervisor runs. Programmatic construction bypasses any schema layer, so
|
|
42
|
+
* every default and bound is re-judged here — misconfiguration fails the
|
|
43
|
+
* bridge instance at load.
|
|
44
|
+
*
|
|
45
|
+
* @param config - Raw `reconnect` config; omission uses the defaults.
|
|
46
|
+
* @param path - Diagnostic prefix naming the config location in thrown messages.
|
|
47
|
+
* @returns The frozen resolved policy.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveReconnectPolicy(config, path) {
|
|
50
|
+
if (config !== undefined) {
|
|
51
|
+
for (const key of Object.keys(config)) {
|
|
52
|
+
if (!Object.hasOwn(RECONNECT_DEFAULTS, key))
|
|
53
|
+
throw new Error(`${path}.${key} is not a reconnect option`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled;
|
|
57
|
+
const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs;
|
|
58
|
+
const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs;
|
|
59
|
+
const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts;
|
|
60
|
+
if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) {
|
|
61
|
+
throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
62
|
+
}
|
|
63
|
+
if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) {
|
|
64
|
+
throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
65
|
+
}
|
|
66
|
+
if (initialDelayMs > maxDelayMs) {
|
|
67
|
+
throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`);
|
|
68
|
+
}
|
|
69
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
70
|
+
throw new Error(`${path}.maxAttempts must be a positive integer`);
|
|
71
|
+
}
|
|
72
|
+
return Object.freeze({ enabled, initialDelayMs, maxDelayMs, maxAttempts });
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Start the supervised connection for one MCP server and keep it alive per
|
|
76
|
+
* the reconnect policy.
|
|
77
|
+
*
|
|
78
|
+
* @param host - The bridge host providing the tool registry and logger.
|
|
79
|
+
* @param config - Resolved bridge config selecting the transport and server identity.
|
|
80
|
+
* @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
|
|
81
|
+
* @param credentials - The composition's credential store, when mounted; OAuth
|
|
82
|
+
* state persists only when it is.
|
|
83
|
+
* @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
|
|
84
|
+
*/
|
|
85
|
+
export function startConnection(host, config, policy, credentials) {
|
|
86
|
+
const label = `mcp-client(${config.serverName})`;
|
|
87
|
+
const opts = {
|
|
88
|
+
registrationFailure: 'contain',
|
|
89
|
+
serverName: config.serverName,
|
|
90
|
+
toolCallTimeoutMs: config.toolCallTimeoutMs
|
|
91
|
+
};
|
|
92
|
+
// The initial sync uses 'throw' when failOnStartupError is configured, so
|
|
93
|
+
// a registration conflict propagates to the startup-await path. Re-syncs
|
|
94
|
+
// and reconnect syncs always contain conflicts.
|
|
95
|
+
const startupOpts = config.failOnStartupError ? { ...opts, registrationFailure: 'throw' } : opts;
|
|
96
|
+
let disposed = false;
|
|
97
|
+
/** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */
|
|
98
|
+
let client;
|
|
99
|
+
/** Close signal paired with {@link client}; captured by dispose before current ownership is cleared. */
|
|
100
|
+
let clientClosed;
|
|
101
|
+
/** Live tool registrations owned by this server; only {@link enqueueSync} and dispose swap it. */
|
|
102
|
+
let disposers = new Map();
|
|
103
|
+
let reconnectTimer;
|
|
104
|
+
/** Consecutive failed connection attempts within the current outage. */
|
|
105
|
+
let failedAttempts = 0;
|
|
106
|
+
/** When the current generation finished connect + initial sync; undefined while down. */
|
|
107
|
+
let connectedAt;
|
|
108
|
+
/** The real error from the first connection attempt, for startup-await diagnostics. */
|
|
109
|
+
let firstAttemptError;
|
|
110
|
+
/** A generation may act only while it is the current one on a live bridge. */
|
|
111
|
+
const isCurrent = (generation) => !disposed && client === generation;
|
|
112
|
+
/**
|
|
113
|
+
* Serializes every syncTools call — initial syncs and notification re-syncs
|
|
114
|
+
* across all generations — so two syncs can never interleave their
|
|
115
|
+
* dispose-previous/register-next swap (which would double-dispose one
|
|
116
|
+
* generation and leak another).
|
|
117
|
+
*/
|
|
118
|
+
let syncChain = Promise.resolve();
|
|
119
|
+
function enqueueSync(generation, syncOpts = opts) {
|
|
120
|
+
const run = syncChain.then(async () => {
|
|
121
|
+
if (!isCurrent(generation))
|
|
122
|
+
return;
|
|
123
|
+
disposers = await syncTools(generation, host, syncOpts, disposers);
|
|
124
|
+
});
|
|
125
|
+
// The chain tail must survive a failed sync; the enqueuing caller owns reporting.
|
|
126
|
+
syncChain = run.catch(() => { });
|
|
127
|
+
return run;
|
|
128
|
+
}
|
|
129
|
+
/** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */
|
|
130
|
+
function generationDown(generation) {
|
|
131
|
+
if (!isCurrent(generation))
|
|
132
|
+
return;
|
|
133
|
+
client = undefined;
|
|
134
|
+
clientClosed = undefined;
|
|
135
|
+
scheduleReconnect();
|
|
136
|
+
}
|
|
137
|
+
/** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */
|
|
138
|
+
function waitForClose(closed) {
|
|
139
|
+
return new Promise(resolve => {
|
|
140
|
+
const timeout = setTimeout(() => {
|
|
141
|
+
resolve(false);
|
|
142
|
+
}, GENERATION_CLOSE_TIMEOUT_MS);
|
|
143
|
+
timeout.unref();
|
|
144
|
+
void closed.then(() => {
|
|
145
|
+
clearTimeout(timeout);
|
|
146
|
+
resolve(true);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function scheduleReconnect() {
|
|
151
|
+
const lostEstablishedConnection = connectedAt !== undefined;
|
|
152
|
+
if (!policy.enabled) {
|
|
153
|
+
const message = lostEstablishedConnection
|
|
154
|
+
? 'connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart'
|
|
155
|
+
: 'connection failed and reconnect is disabled — no tools were registered; reload the plugin or restart the Host to connect';
|
|
156
|
+
host.logger.error(`${label}: ${message}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// A connection that stayed up past the stability window (= maxDelayMs, the
|
|
160
|
+
// longest backoff spacing) ended the previous outage: start a fresh budget.
|
|
161
|
+
if (connectedAt !== undefined && Date.now() - connectedAt >= policy.maxDelayMs)
|
|
162
|
+
failedAttempts = 0;
|
|
163
|
+
connectedAt = undefined;
|
|
164
|
+
failedAttempts += 1;
|
|
165
|
+
if (failedAttempts > policy.maxAttempts) {
|
|
166
|
+
// Enqueue the give-up disposal so it cannot race an in-flight sync's
|
|
167
|
+
// phase-2 swap (which checks isCurrent inside the queue).
|
|
168
|
+
syncChain = syncChain.then(() => {
|
|
169
|
+
for (const dispose of disposers.values())
|
|
170
|
+
dispose();
|
|
171
|
+
disposers = new Map();
|
|
172
|
+
});
|
|
173
|
+
host.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1));
|
|
177
|
+
const action = lostEstablishedConnection ? 'connection lost; reconnecting' : 'connection failed; retrying';
|
|
178
|
+
host.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`);
|
|
179
|
+
reconnectTimer = setTimeout(() => {
|
|
180
|
+
reconnectTimer = undefined;
|
|
181
|
+
settling = connectGeneration(false);
|
|
182
|
+
}, delayMs);
|
|
183
|
+
// An armed reconnect timer must never hold the process open on its own.
|
|
184
|
+
reconnectTimer.unref();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* One connection attempt: fresh transport + client (the MCP SDK binds a
|
|
188
|
+
* Protocol to one transport for life), connect, then queue the initial tool
|
|
189
|
+
* sync. The startup flag belongs to the attempt rather than the shared sync
|
|
190
|
+
* queue, so an early notification cannot consume strict startup semantics.
|
|
191
|
+
* Every failure funnels through {@link generationDown}; success arms the
|
|
192
|
+
* onclose-driven disconnect path. Never rejects.
|
|
193
|
+
*
|
|
194
|
+
* @param startup - Whether this is the bridge's activation attempt.
|
|
195
|
+
*/
|
|
196
|
+
async function connectGeneration(startup) {
|
|
197
|
+
const generation = new Client({ name: 'dsh-agent-plugins-market', version: '0.1.0' }, { capabilities: {} });
|
|
198
|
+
const closed = Promise.withResolvers();
|
|
199
|
+
let attemptSettled = false;
|
|
200
|
+
let closeObserved = false;
|
|
201
|
+
const hasClosed = () => closeObserved;
|
|
202
|
+
client = generation;
|
|
203
|
+
clientClosed = closed.promise;
|
|
204
|
+
generation.onclose = () => {
|
|
205
|
+
closeObserved = true;
|
|
206
|
+
closed.resolve();
|
|
207
|
+
// A failed connect owns its close barrier in the catch path below. An
|
|
208
|
+
// established generation can transition down directly from this signal.
|
|
209
|
+
if (attemptSettled)
|
|
210
|
+
generationDown(generation);
|
|
211
|
+
};
|
|
212
|
+
// Registered before connect so a list change during the initial sync is
|
|
213
|
+
// queued behind it rather than dropped.
|
|
214
|
+
generation.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
215
|
+
if (!isCurrent(generation))
|
|
216
|
+
return;
|
|
217
|
+
host.logger.info(`${label}: tool list changed, re-syncing`);
|
|
218
|
+
try {
|
|
219
|
+
await enqueueSync(generation);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
// Fetch-phase failure: the previous generation is still registered
|
|
223
|
+
// and `disposers` still owns it — keep serving the last good list.
|
|
224
|
+
if (!disposed)
|
|
225
|
+
host.logger.error(`${label}: tool re-sync failed: ${String(error)}`);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
const { transport, oauthProvider } = createTransport(config, credentials, (message) => {
|
|
229
|
+
host.logger.info(message);
|
|
230
|
+
});
|
|
231
|
+
try {
|
|
232
|
+
await generation.connect(transport);
|
|
233
|
+
if (hasClosed()) {
|
|
234
|
+
attemptSettled = true;
|
|
235
|
+
generationDown(generation);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
await enqueueSync(generation, startup ? startupOpts : opts);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (firstAttemptError === undefined)
|
|
242
|
+
firstAttemptError = error;
|
|
243
|
+
// Disposal clears current ownership before it closes the generation, so
|
|
244
|
+
// only a live supervisor reports an attempt failure.
|
|
245
|
+
if (isCurrent(generation))
|
|
246
|
+
host.logger.warn(`${label}: connection attempt failed: ${String(error)}`);
|
|
247
|
+
// A pending browser leg outlives the initialize timeout: the human is
|
|
248
|
+
// mid-approval. Hold this generation open — transport and loopback
|
|
249
|
+
// listener stay alive — until the leg settles; abandoning it would
|
|
250
|
+
// discard the very authorization the user is completing.
|
|
251
|
+
const leg = oauthProvider?.browserLeg;
|
|
252
|
+
if (leg !== undefined) {
|
|
253
|
+
host.logger.warn(`${label}: waiting for browser authorization (up to 5 minutes) — approve access in the page that opened`);
|
|
254
|
+
const legSettled = await leg;
|
|
255
|
+
attemptSettled = true;
|
|
256
|
+
if (!isCurrent(generation))
|
|
257
|
+
return;
|
|
258
|
+
if (hasClosed()) {
|
|
259
|
+
generationDown(generation);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!legSettled) {
|
|
263
|
+
generationDown(generation);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// The exchange succeeded inside this generation: finish establishing
|
|
267
|
+
// it (tool sync + connected bookkeeping) instead of respawning.
|
|
268
|
+
try {
|
|
269
|
+
await enqueueSync(generation, startup ? startupOpts : opts);
|
|
270
|
+
}
|
|
271
|
+
catch (syncError) {
|
|
272
|
+
if (isCurrent(generation))
|
|
273
|
+
host.logger.warn(`${label}: tool sync after authorization failed: ${String(syncError)}`);
|
|
274
|
+
try {
|
|
275
|
+
await generation.close();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* transport already gone */
|
|
279
|
+
}
|
|
280
|
+
const quiescedSync = hasClosed() || (await waitForClose(closed.promise));
|
|
281
|
+
if (!quiescedSync) {
|
|
282
|
+
client = undefined;
|
|
283
|
+
clientClosed = undefined;
|
|
284
|
+
host.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
generationDown(generation);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (hasClosed()) {
|
|
291
|
+
generationDown(generation);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!isCurrent(generation))
|
|
295
|
+
return;
|
|
296
|
+
connectedAt = Date.now();
|
|
297
|
+
if (failedAttempts > 0)
|
|
298
|
+
host.logger.info(`${label}: authorized and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
await generation.close();
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
/* transport already gone */
|
|
306
|
+
}
|
|
307
|
+
const quiesced = hasClosed() || (await waitForClose(closed.promise));
|
|
308
|
+
attemptSettled = true;
|
|
309
|
+
if (!isCurrent(generation))
|
|
310
|
+
return;
|
|
311
|
+
if (!quiesced) {
|
|
312
|
+
client = undefined;
|
|
313
|
+
clientClosed = undefined;
|
|
314
|
+
host.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
generationDown(generation);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
attemptSettled = true;
|
|
321
|
+
if (hasClosed()) {
|
|
322
|
+
generationDown(generation);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (!isCurrent(generation))
|
|
326
|
+
return;
|
|
327
|
+
connectedAt = Date.now();
|
|
328
|
+
if (failedAttempts > 0)
|
|
329
|
+
host.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`);
|
|
330
|
+
}
|
|
331
|
+
/** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */
|
|
332
|
+
let settling = connectGeneration(true);
|
|
333
|
+
// The ready promise settles when the first attempt finishes (regardless of
|
|
334
|
+
// success). If the first attempt fails and reconnect is enabled, the
|
|
335
|
+
// supervisor is already scheduling a retry — ready just reports the outcome.
|
|
336
|
+
const ready = settling.then(() => {
|
|
337
|
+
// After settling: if client is set the initial connect+sync succeeded.
|
|
338
|
+
// If not, the supervisor either scheduled a retry (error logged) or gave
|
|
339
|
+
// up (error logged). Either way the outcome is reported with the real error.
|
|
340
|
+
// Note: settling.then() is a microtask; stdio onclose is a macrotask — so
|
|
341
|
+
// a server that crashes AFTER a successful initial sync cannot flip client
|
|
342
|
+
// to undefined before this continuation runs.
|
|
343
|
+
if (client !== undefined)
|
|
344
|
+
return {};
|
|
345
|
+
/* v8 ignore next -- defensive: firstAttemptError is always set when connect/sync fails */
|
|
346
|
+
return { error: firstAttemptError ?? new Error(`${label}: initial connection failed`) };
|
|
347
|
+
});
|
|
348
|
+
return {
|
|
349
|
+
ready,
|
|
350
|
+
async dispose() {
|
|
351
|
+
disposed = true;
|
|
352
|
+
if (reconnectTimer !== undefined) {
|
|
353
|
+
clearTimeout(reconnectTimer);
|
|
354
|
+
reconnectTimer = undefined;
|
|
355
|
+
}
|
|
356
|
+
const current = client;
|
|
357
|
+
const currentClosed = clientClosed;
|
|
358
|
+
client = undefined;
|
|
359
|
+
clientClosed = undefined;
|
|
360
|
+
if (current !== undefined) {
|
|
361
|
+
try {
|
|
362
|
+
await current.close();
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
/* transport already gone */
|
|
366
|
+
}
|
|
367
|
+
if (currentClosed !== undefined && !(await waitForClose(currentClosed))) {
|
|
368
|
+
host.logger.error(`${label}: generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms during disposal — server shutdown may be incomplete`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Quiesce, don't just request it: the in-flight attempt enqueues its
|
|
372
|
+
// sync before settling, so awaiting both leaves `disposers` final.
|
|
373
|
+
await settling;
|
|
374
|
+
await syncChain;
|
|
375
|
+
for (const dispose of disposers.values())
|
|
376
|
+
dispose();
|
|
377
|
+
disposers = new Map();
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local ports of the tiny host seams the self-built MCP client needs.
|
|
3
|
+
*
|
|
4
|
+
* The market plugin must stay self-contained at runtime: it cannot import
|
|
5
|
+
* host-internal packages that are not published at compatible versions, and
|
|
6
|
+
* every one of these seams is a small pure function or constant. Each port
|
|
7
|
+
* below is copied verbatim (semantics, not prose) from the harness source at
|
|
8
|
+
* `0.1.1-rc.2` and carries the upstream reference it came from, so a future
|
|
9
|
+
* host change can be re-diffed against `~/workspace/deepseek-harness`.
|
|
10
|
+
*
|
|
11
|
+
* Anything larger than a handful of lines (the JSON Schema subset validator)
|
|
12
|
+
* lives in its own module.
|
|
13
|
+
*
|
|
14
|
+
* @module runtime/mcp-client/host-seams
|
|
15
|
+
*/
|
|
16
|
+
// ---- dsh-timeout: MAX_TIMER_DELAY_MS (packages/util/timeout/src/index.ts) ----
|
|
17
|
+
/**
|
|
18
|
+
* The largest delay `setTimeout` accepts without overflowing to a 1ms clamp
|
|
19
|
+
* (2^31 - 1). Shared guard for reconnect and callback timers.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
22
|
+
// ---- dsh-subprocess: scrubbedParentEnv (packages/subprocess/subprocess/src/index.ts) ----
|
|
23
|
+
/** `DSH_*` environment namespace owned by the harness. */
|
|
24
|
+
export const DSH_ENV_PREFIX = 'DSH_';
|
|
25
|
+
/**
|
|
26
|
+
* Credential-shaped environment names are NOT forwarded to MCP server child
|
|
27
|
+
* processes (the harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a
|
|
28
|
+
* spawned server implicitly). Deliberately supplied env entries survive
|
|
29
|
+
* because the config's explicit env merges after the scrub.
|
|
30
|
+
*/
|
|
31
|
+
export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i;
|
|
32
|
+
/**
|
|
33
|
+
* The ambient parent environment minus credential-shaped names and minus all
|
|
34
|
+
* `DSH_*` names — the canonical base every harness child starts from. `PATH`,
|
|
35
|
+
* `HOME`, locale, and proxy variables survive, so child CLIs run normally;
|
|
36
|
+
* harness identity never leaks implicitly. Both scrubs match case-insensitively:
|
|
37
|
+
* Windows environment names are case-insensitive, so a parent `dsh_*` entry
|
|
38
|
+
* would otherwise survive and read back as `$env:DSH_*` in the child.
|
|
39
|
+
* @returns a fresh environment object safe to hand to a child spawn.
|
|
40
|
+
*/
|
|
41
|
+
export function scrubbedParentEnv() {
|
|
42
|
+
const env = {};
|
|
43
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
44
|
+
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX))
|
|
45
|
+
env[key] = value;
|
|
46
|
+
}
|
|
47
|
+
return env;
|
|
48
|
+
}
|
|
49
|
+
/** Key segments are lowercase hyphenated identifiers. */
|
|
50
|
+
const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
51
|
+
/**
|
|
52
|
+
* Brand a scope and an id as a {@link CredentialKey}.
|
|
53
|
+
* @param scope - the owning plugin's registered name (here: the `mcp-auth` record scope).
|
|
54
|
+
* @param id - the plugin's own addressing unit (here: the folded serverName).
|
|
55
|
+
* @returns the branded key.
|
|
56
|
+
* @throws TypeError when either segment is not a lowercase hyphenated identifier.
|
|
57
|
+
*/
|
|
58
|
+
export function credentialKey(scope, id) {
|
|
59
|
+
for (const segment of [scope, id]) {
|
|
60
|
+
if (!KEY_SEGMENT_PATTERN.test(segment)) {
|
|
61
|
+
throw new TypeError(`credential key segment "${segment}" must match ${String(KEY_SEGMENT_PATTERN)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return `${scope}/${id}`;
|
|
65
|
+
}
|
|
66
|
+
// ---- dsh-attachment: image admission error routing (packages/attachment/attachment/src/error.ts) ----
|
|
67
|
+
/** Caller-correctable attachment failure codes raised while admitting image input. */
|
|
68
|
+
const IMAGE_ADMISSION_ERROR_CODES = [
|
|
69
|
+
'TOO_MANY_IMAGES',
|
|
70
|
+
'IMAGES_TOO_LARGE',
|
|
71
|
+
'UNSUPPORTED_IMAGE_TYPE',
|
|
72
|
+
'INVALID_IMAGE_BASE64',
|
|
73
|
+
'INVALID_IMAGE',
|
|
74
|
+
'IMAGE_TYPE_MISMATCH',
|
|
75
|
+
'IMAGE_TOO_LARGE',
|
|
76
|
+
'IMAGE_TOO_MANY_PIXELS',
|
|
77
|
+
'IMAGE_DIMENSION_TOO_LARGE'
|
|
78
|
+
];
|
|
79
|
+
/** Runtime membership for structurally compatible errors crossing package boundaries. */
|
|
80
|
+
const IMAGE_ADMISSION_ERROR_CODE_SET = new Set(IMAGE_ADMISSION_ERROR_CODES);
|
|
81
|
+
/**
|
|
82
|
+
* Distinguish caller-correctable image admission failures from storage faults,
|
|
83
|
+
* so an admission refusal projects text diagnostics while a storage fault is
|
|
84
|
+
* reported as such.
|
|
85
|
+
*/
|
|
86
|
+
export function isImageAdmissionError(error) {
|
|
87
|
+
return error instanceof Error && 'code' in error && typeof error.code === 'string' && IMAGE_ADMISSION_ERROR_CODE_SET.has(error.code);
|
|
88
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local port of the harness's enforced JSON Schema subset validator
|
|
3
|
+
* (`@deepseek-ai/dsh-tools` `json-schema.ts`, rc.2) — narrowed to the one use
|
|
4
|
+
* the MCP bridge has: deciding whether an MCP server's advertised
|
|
5
|
+
* `outputSchema` may ride a tool registration as a structured-output schema.
|
|
6
|
+
*
|
|
7
|
+
* The upstream validator is 656 lines with realm-safe JSON intrinsics; this
|
|
8
|
+
* port keeps the *contract* — a closed keyword table, one scalar `type`,
|
|
9
|
+
* nested structure, and "unsupported keywords reject rather than pass
|
|
10
|
+
* unenforced" — with a conservative structural check. A schema this check
|
|
11
|
+
* wrongly rejects only loses structured typing (the bridge falls back to
|
|
12
|
+
* untyped JSON, exactly the upstream failure mode); one it wrongly accepts
|
|
13
|
+
* would flow into the host registry, so every branch here fails closed.
|
|
14
|
+
*
|
|
15
|
+
* @module runtime/mcp-client/json-schema-subset
|
|
16
|
+
*/
|
|
17
|
+
/** Constraint keywords the subset enforces; anything else rejects. */
|
|
18
|
+
const CONSTRAINT_KEYWORDS = new Set(['type', 'oneOf', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const']);
|
|
19
|
+
/** Annotation keywords accepted but never enforced. */
|
|
20
|
+
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples']);
|
|
21
|
+
const SCHEMA_TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'];
|
|
22
|
+
/** Narrow a raw value to a plain string-keyed record. */
|
|
23
|
+
function isRecord(value) {
|
|
24
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
/** Whether a value is a JSON scalar (the only `enum`/`const` vocabulary). */
|
|
27
|
+
function isJsonScalar(value) {
|
|
28
|
+
return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
|
|
29
|
+
}
|
|
30
|
+
/** Whether a value is a lossless JSON composite (round-trip checked). */
|
|
31
|
+
function isLosslessJson(value) {
|
|
32
|
+
if (!isRecord(value) && !Array.isArray(value))
|
|
33
|
+
return isJsonScalar(value);
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(JSON.stringify(value)) !== undefined || value !== undefined;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Whether one scalar satisfies a declared schema type (integers include whole numbers). */
|
|
42
|
+
function scalarMatches(schemaType, value) {
|
|
43
|
+
if (schemaType === undefined)
|
|
44
|
+
return true;
|
|
45
|
+
switch (schemaType) {
|
|
46
|
+
case 'string':
|
|
47
|
+
return typeof value === 'string';
|
|
48
|
+
case 'number':
|
|
49
|
+
return typeof value === 'number';
|
|
50
|
+
case 'integer':
|
|
51
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
52
|
+
case 'boolean':
|
|
53
|
+
return typeof value === 'boolean';
|
|
54
|
+
case 'null':
|
|
55
|
+
return value === null;
|
|
56
|
+
/* v8 ignore next -- scalar values never satisfy the container types */
|
|
57
|
+
case 'object':
|
|
58
|
+
case 'array':
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Assert that a raw schema uses only the enforced subset. Annotation-only
|
|
64
|
+
* schemas are accepted as the standard unconstrained-JSON form.
|
|
65
|
+
* @param schema - untrusted raw JSON Schema.
|
|
66
|
+
* @throws Error listing every violation when the schema leaves the subset.
|
|
67
|
+
*/
|
|
68
|
+
export function assertSupportedJsonSchema(schema) {
|
|
69
|
+
const violations = [];
|
|
70
|
+
checkSchemaNode(schema, 'schema', violations, new Set());
|
|
71
|
+
if (violations.length > 0) {
|
|
72
|
+
throw new Error(`unsupported JSON schema: ${violations.join('; ')}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Depth-first walk of one schema node; violations accumulate in walk order. */
|
|
76
|
+
function checkSchemaNode(node, path, violations, seen) {
|
|
77
|
+
if (seen.has(node)) {
|
|
78
|
+
violations.push(`${path} is recursive; the subset does not accept recursive schemas`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!isRecord(node)) {
|
|
82
|
+
violations.push(`${path} must be an object`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
seen.add(node);
|
|
86
|
+
try {
|
|
87
|
+
for (const key of Object.keys(node)) {
|
|
88
|
+
if (!CONSTRAINT_KEYWORDS.has(key) && !ANNOTATION_KEYWORDS.has(key)) {
|
|
89
|
+
violations.push(`${path}.${key} is not part of the enforced subset`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const hasType = Object.hasOwn(node, 'type');
|
|
93
|
+
const hasOneOf = Object.hasOwn(node, 'oneOf');
|
|
94
|
+
const type = hasType ? node.type : undefined;
|
|
95
|
+
if (hasType && !SCHEMA_TYPES.includes(type)) {
|
|
96
|
+
violations.push(`${path}.type must be one of ${SCHEMA_TYPES.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
if (hasOneOf) {
|
|
99
|
+
if (hasType)
|
|
100
|
+
violations.push(`${path} must not declare both type and oneOf`);
|
|
101
|
+
const branches = node.oneOf;
|
|
102
|
+
if (!Array.isArray(branches) || branches.length < 2) {
|
|
103
|
+
violations.push(`${path}.oneOf requires at least two branches`);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
for (const [index, branch] of branches.entries()) {
|
|
107
|
+
checkSchemaNode(branch, `${path}.oneOf[${index}]`, violations, seen);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (Object.hasOwn(node, 'properties')) {
|
|
112
|
+
if (type !== 'object')
|
|
113
|
+
violations.push(`${path}.properties is only valid on an object schema`);
|
|
114
|
+
const properties = node.properties;
|
|
115
|
+
if (!isRecord(properties)) {
|
|
116
|
+
violations.push(`${path}.properties must be an object of schemas`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
120
|
+
checkSchemaNode(property, `${path}.properties.${name}`, violations, seen);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (Object.hasOwn(node, 'required')) {
|
|
125
|
+
if (type !== 'object')
|
|
126
|
+
violations.push(`${path}.required is only valid on an object schema`);
|
|
127
|
+
const required = node.required;
|
|
128
|
+
if (!Array.isArray(required) || !required.every(entry => typeof entry === 'string')) {
|
|
129
|
+
violations.push(`${path}.required must be an array of property names`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const properties = isRecord(node.properties) ? node.properties : undefined;
|
|
133
|
+
for (const name of required) {
|
|
134
|
+
if (properties === undefined || !Object.hasOwn(properties, name)) {
|
|
135
|
+
violations.push(`${path}.required lists "${name}" which has no declared property`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (Object.hasOwn(node, 'additionalProperties')) {
|
|
141
|
+
if (type !== 'object')
|
|
142
|
+
violations.push(`${path}.additionalProperties is only valid on an object schema`);
|
|
143
|
+
if (typeof node.additionalProperties !== 'boolean') {
|
|
144
|
+
violations.push(`${path}.additionalProperties must be a boolean`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (Object.hasOwn(node, 'items')) {
|
|
148
|
+
if (type !== 'array')
|
|
149
|
+
violations.push(`${path}.items is only valid on an array schema`);
|
|
150
|
+
checkSchemaNode(node.items, `${path}.items`, violations, seen);
|
|
151
|
+
}
|
|
152
|
+
if (Object.hasOwn(node, 'enum')) {
|
|
153
|
+
const values = node.enum;
|
|
154
|
+
if (!Array.isArray(values) || values.length === 0 || !values.every(isJsonScalar)) {
|
|
155
|
+
violations.push(`${path}.enum must be a non-empty array of JSON scalars`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
for (const value of values) {
|
|
159
|
+
if (!scalarMatches(type, value)) {
|
|
160
|
+
violations.push(`${path}.enum values must match the declared ${String(type)} type`);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (Object.hasOwn(node, 'const')) {
|
|
167
|
+
const value = node.const;
|
|
168
|
+
if (!isJsonScalar(value) || !scalarMatches(type, value)) {
|
|
169
|
+
violations.push(`${path}.const must be a scalar matching the declared schema type`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const annotation of ['default', 'examples']) {
|
|
173
|
+
if (Object.hasOwn(node, annotation) && !isLosslessJson(node[annotation])) {
|
|
174
|
+
violations.push(`${path}.${annotation} must be lossless JSON`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
seen.delete(node);
|
|
180
|
+
}
|
|
181
|
+
}
|