mcp-compression-proxy 1.0.3 → 1.1.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/CHANGELOG.md +16 -0
- package/README.md +227 -659
- package/dist/cli/commands.d.ts +18 -0
- package/dist/cli/commands.js +152 -13
- package/dist/cli/daemon.js +133 -49
- package/dist/cli/index.js +147 -7
- package/dist/cli/payload-interceptor.d.ts +71 -2
- package/dist/cli/payload-interceptor.js +214 -36
- package/dist/cli/runtime-mode.d.ts +3 -0
- package/dist/cli/runtime-mode.js +12 -0
- package/dist/cli/runtime-paths.d.ts +15 -0
- package/dist/cli/runtime-paths.js +26 -0
- package/dist/config/loader.d.ts +4 -0
- package/dist/config/loader.js +92 -1
- package/dist/config/schema.d.ts +90 -1
- package/dist/config/schema.js +95 -13
- package/dist/index.js +319 -28
- package/dist/mcp/call-script.d.ts +33 -0
- package/dist/mcp/call-script.js +153 -0
- package/dist/mcp/client-manager.d.ts +110 -27
- package/dist/mcp/client-manager.js +706 -83
- package/dist/mcp/tool-call-executor.d.ts +11 -0
- package/dist/mcp/tool-call-executor.js +94 -0
- package/dist/services/compression-cache.d.ts +18 -0
- package/dist/services/compression-cache.js +32 -0
- package/dist/services/session-manager.js +5 -0
- package/dist/services/stats-service.d.ts +4 -0
- package/dist/services/stats-service.js +18 -39
- package/dist/types/index.d.ts +59 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
|
@@ -1,13 +1,75 @@
|
|
|
1
1
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
2
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
3
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
3
4
|
import { SERVER_NAME, VERSION } from '../version.js';
|
|
5
|
+
export const DEFAULT_SOFT_MAX_CONNECTION_AGE_SECONDS = 3600;
|
|
6
|
+
export const DEFAULT_HARD_MAX_CONNECTION_AGE_SECONDS = 28_800;
|
|
4
7
|
/**
|
|
5
|
-
*
|
|
8
|
+
* Stable serialization of a resolved server config, used to decide whether a
|
|
9
|
+
* live connection still matches what servers.json now asks for.
|
|
10
|
+
*
|
|
11
|
+
* Keys are sorted because JSON.stringify follows insertion order: an operator
|
|
12
|
+
* swapping two lines inside a server entry would otherwise read as a changed
|
|
13
|
+
* server and bounce a perfectly healthy backend. Absent and explicitly
|
|
14
|
+
* `undefined` fields collapse together for the same reason.
|
|
15
|
+
*/
|
|
16
|
+
function configFingerprint(config) {
|
|
17
|
+
// Only the fields that define the live connection. `enabled` is already
|
|
18
|
+
// handled by filtering before reconcile, and `timeout` is consumed once at
|
|
19
|
+
// connect time - hashing either meant deleting a redundant "enabled": true
|
|
20
|
+
// or raising a timeout closed a healthy backend and respawned its process,
|
|
21
|
+
// which is exactly the churn the key-sorting below exists to avoid.
|
|
22
|
+
const { name, command, args, env, inheritEnv, url, headers, softMaxConnectionAgeSeconds, hardMaxConnectionAgeSeconds, maxConnectionAgeSeconds, authErrorPatterns, authRetryTools, } = config;
|
|
23
|
+
const connectionFields = {
|
|
24
|
+
name,
|
|
25
|
+
command,
|
|
26
|
+
args,
|
|
27
|
+
env,
|
|
28
|
+
inheritEnv,
|
|
29
|
+
url,
|
|
30
|
+
headers,
|
|
31
|
+
softMaxConnectionAgeSeconds,
|
|
32
|
+
hardMaxConnectionAgeSeconds,
|
|
33
|
+
maxConnectionAgeSeconds,
|
|
34
|
+
authErrorPatterns,
|
|
35
|
+
authRetryTools,
|
|
36
|
+
};
|
|
37
|
+
return JSON.stringify(connectionFields, (_key, value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
38
|
+
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
39
|
+
: value);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort message for something thrown.
|
|
43
|
+
*
|
|
44
|
+
* `instanceof Error` is not reliable here: `new URL()` is Node core, so on a
|
|
45
|
+
* malformed address it throws an Error built in a different realm from this
|
|
46
|
+
* module's, and the check silently fails - turning a perfectly good "Invalid
|
|
47
|
+
* URL" into "Unknown error" on the one status field the user reads to find out
|
|
48
|
+
* what went wrong.
|
|
49
|
+
*/
|
|
50
|
+
function describeError(error) {
|
|
51
|
+
if (error instanceof Error)
|
|
52
|
+
return error.message;
|
|
53
|
+
const message = error?.message;
|
|
54
|
+
if (typeof message === 'string' && message !== '')
|
|
55
|
+
return message;
|
|
56
|
+
return 'Unknown error';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Manages backend MCP processes as leased connection generations.
|
|
60
|
+
*
|
|
61
|
+
* READY connections may be leased by concurrent callers. Soft-expired
|
|
62
|
+
* connections are replaced on the next acquisition. Hard-expired connections
|
|
63
|
+
* enter DRAINING immediately and close after their final lease is released.
|
|
6
64
|
*/
|
|
7
65
|
export class MCPClientManager {
|
|
8
|
-
|
|
66
|
+
slots = new Map();
|
|
9
67
|
logger;
|
|
10
|
-
DEFAULT_TIMEOUT_MS =
|
|
68
|
+
DEFAULT_TIMEOUT_MS = 30_000;
|
|
69
|
+
RECONNECT_BASE_MS = 1_000;
|
|
70
|
+
RECONNECT_MAX_MS = 30_000;
|
|
71
|
+
configWatchTimer;
|
|
72
|
+
shuttingDown = false;
|
|
11
73
|
constructor(logger) {
|
|
12
74
|
this.logger = logger;
|
|
13
75
|
}
|
|
@@ -40,14 +102,10 @@ export class MCPClientManager {
|
|
|
40
102
|
*
|
|
41
103
|
* The stdio transport only inherits a small allowlist of "safe" variables
|
|
42
104
|
* (PATH, HOME, ...), so anything else the user exported - API tokens, base
|
|
43
|
-
* URLs - never reaches the child unless it is passed explicitly.
|
|
44
|
-
* we forward the proxy's full environment, matching what users expect from a
|
|
45
|
-
* process they launched themselves. `inheritEnv` narrows that when a server
|
|
46
|
-
* should not see unrelated secrets.
|
|
105
|
+
* URLs - never reaches the child unless it is passed explicitly.
|
|
47
106
|
*/
|
|
48
107
|
buildEnv(config) {
|
|
49
108
|
const inherit = config.inheritEnv ?? true;
|
|
50
|
-
// `false` defers entirely to the transport's safe defaults.
|
|
51
109
|
if (inherit === false) {
|
|
52
110
|
return config.env;
|
|
53
111
|
}
|
|
@@ -55,142 +113,707 @@ export class MCPClientManager {
|
|
|
55
113
|
const inherited = {};
|
|
56
114
|
for (const name of names) {
|
|
57
115
|
const value = process.env[name];
|
|
58
|
-
if (value === undefined)
|
|
59
|
-
continue;
|
|
60
|
-
// Skip exported shell functions, which are a known injection vector.
|
|
61
|
-
if (value.startsWith('()'))
|
|
116
|
+
if (value === undefined || value.startsWith('()'))
|
|
62
117
|
continue;
|
|
63
118
|
inherited[name] = value;
|
|
64
119
|
}
|
|
65
|
-
// Explicit `env` entries always win over inherited ones.
|
|
66
120
|
return { ...inherited, ...config.env };
|
|
67
121
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
* @param servers - Server configurations to initialize
|
|
71
|
-
* @param defaultTimeout - Optional default timeout in seconds (overrides class default)
|
|
72
|
-
* @param defaultInheritEnv - Optional default env inheritance policy (overridden per-server)
|
|
73
|
-
*/
|
|
74
|
-
async initializeServers(servers, defaultTimeout, defaultInheritEnv) {
|
|
75
|
-
this.logger.info({ count: servers.length }, 'Initializing MCP servers');
|
|
76
|
-
// Apply defaults to servers that don't specify their own
|
|
77
|
-
const serversWithTimeout = servers.map(server => ({
|
|
122
|
+
resolveConfig(server, defaultTimeout, defaultInheritEnv, defaults) {
|
|
123
|
+
return {
|
|
78
124
|
...server,
|
|
79
125
|
timeout: server.timeout ?? defaultTimeout,
|
|
80
126
|
inheritEnv: server.inheritEnv ?? defaultInheritEnv,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.logger.info({ connected: connectedCount, total: servers.length }, 'MCP servers initialization complete');
|
|
127
|
+
softMaxConnectionAgeSeconds: server.softMaxConnectionAgeSeconds ??
|
|
128
|
+
server.maxConnectionAgeSeconds ??
|
|
129
|
+
defaults.softMaxConnectionAgeSeconds ??
|
|
130
|
+
defaults.maxConnectionAgeSeconds ??
|
|
131
|
+
DEFAULT_SOFT_MAX_CONNECTION_AGE_SECONDS,
|
|
132
|
+
hardMaxConnectionAgeSeconds: server.hardMaxConnectionAgeSeconds ??
|
|
133
|
+
defaults.hardMaxConnectionAgeSeconds ??
|
|
134
|
+
DEFAULT_HARD_MAX_CONNECTION_AGE_SECONDS,
|
|
135
|
+
authErrorPatterns: [...(server.authErrorPatterns ?? defaults.authErrorPatterns ?? [])],
|
|
136
|
+
authRetryTools: [...(server.authRetryTools ?? defaults.authRetryTools ?? [])],
|
|
137
|
+
};
|
|
93
138
|
}
|
|
94
139
|
/**
|
|
95
|
-
*
|
|
140
|
+
* Pick the transport for a server.
|
|
141
|
+
*
|
|
142
|
+
* `url` is the whole discriminator - the config schema makes `command` and
|
|
143
|
+
* `url` mutually exclusive, so there is nothing else to inspect. `buildEnv`
|
|
144
|
+
* falls away on the remote branch because there is no child process to hand
|
|
145
|
+
* an environment to; credentials travel as headers instead.
|
|
96
146
|
*/
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
147
|
+
buildTransport(config) {
|
|
148
|
+
if (config.url) {
|
|
149
|
+
return new StreamableHTTPClientTransport(new URL(config.url), {
|
|
150
|
+
requestInit: config.headers ? { headers: config.headers } : undefined,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const command = config.command;
|
|
154
|
+
if (!command) {
|
|
155
|
+
throw new Error(`Server "${config.name}" has neither "command" nor "url" - config validation should have rejected it`);
|
|
156
|
+
}
|
|
157
|
+
return new StdioClientTransport({
|
|
158
|
+
command,
|
|
105
159
|
args: config.args,
|
|
106
160
|
env: this.buildEnv(config),
|
|
107
161
|
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Initialize all configured slots and eagerly start their first generation.
|
|
165
|
+
* A failed initial connection remains configured and is retried lazily later.
|
|
166
|
+
*/
|
|
167
|
+
async initializeServers(servers, defaultTimeout, defaultInheritEnv, lifecycleDefaults = {}) {
|
|
168
|
+
this.logger.info({ count: servers.length }, 'Initializing MCP servers');
|
|
169
|
+
const slots = servers.map((server) => {
|
|
170
|
+
const config = this.resolveConfig(server, defaultTimeout, defaultInheritEnv, lifecycleDefaults);
|
|
171
|
+
const slot = {
|
|
172
|
+
config,
|
|
173
|
+
draining: new Set(),
|
|
174
|
+
nextGeneration: 0,
|
|
175
|
+
consecutiveFailures: 0,
|
|
176
|
+
recycleCount: 0,
|
|
177
|
+
authInvalidations: 0,
|
|
178
|
+
reconnectAttempt: 0,
|
|
179
|
+
disposed: false,
|
|
180
|
+
};
|
|
181
|
+
this.slots.set(config.name, slot);
|
|
182
|
+
return slot;
|
|
183
|
+
});
|
|
184
|
+
await Promise.allSettled(slots.map(async (slot) => {
|
|
185
|
+
try {
|
|
186
|
+
await this.ensureConnection(slot);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
this.logger.error({ server: slot.config.name, error }, 'Failed to connect to MCP server');
|
|
190
|
+
}
|
|
191
|
+
}));
|
|
192
|
+
const connectedCount = Array.from(this.slots.values()).filter((slot) => slot.current?.state === 'ready').length;
|
|
193
|
+
this.logger.info({ connected: connectedCount, total: servers.length }, 'MCP servers initialization complete');
|
|
194
|
+
}
|
|
195
|
+
async createConnection(slot) {
|
|
196
|
+
const { config } = slot;
|
|
197
|
+
const timeoutMs = config.timeout ? config.timeout * 1000 : this.DEFAULT_TIMEOUT_MS;
|
|
198
|
+
slot.lastAttemptAt = Date.now();
|
|
199
|
+
this.logger.info({
|
|
200
|
+
server: config.name,
|
|
201
|
+
timeoutMs,
|
|
202
|
+
softMaxConnectionAgeSeconds: config.softMaxConnectionAgeSeconds,
|
|
203
|
+
hardMaxConnectionAgeSeconds: config.hardMaxConnectionAgeSeconds,
|
|
204
|
+
}, 'Connecting to MCP server');
|
|
108
205
|
const client = new Client({
|
|
109
206
|
name: SERVER_NAME,
|
|
110
207
|
version: VERSION,
|
|
111
208
|
}, {
|
|
112
209
|
capabilities: {},
|
|
113
210
|
});
|
|
211
|
+
// Built inside the try so a transport that cannot be constructed at all -
|
|
212
|
+
// `url` missing its scheme is schema-valid and throws here - still lands in
|
|
213
|
+
// the catch and gets recorded. Outside it, the server simply disappeared
|
|
214
|
+
// from getServerStatuses() and looked like it had never been configured.
|
|
215
|
+
let transport;
|
|
114
216
|
try {
|
|
217
|
+
transport = this.buildTransport(config);
|
|
115
218
|
const connectPromise = client.connect(transport);
|
|
116
|
-
// If the timeout wins the race below, this promise may still reject on its
|
|
117
|
-
// own later; swallow it so it doesn't surface as an unhandled rejection.
|
|
118
219
|
connectPromise.catch(() => { });
|
|
119
220
|
await this.withTimeout(connectPromise, timeoutMs);
|
|
120
|
-
this
|
|
221
|
+
// The world may have moved while this connect was in flight: a shutdown
|
|
222
|
+
// completed, or a config reload dropped this server. Adopting the
|
|
223
|
+
// connection now would resurrect a backend nobody will ever close, so
|
|
224
|
+
// hand it straight back instead.
|
|
225
|
+
const unwanted = this.shuttingDown || slot.disposed || this.slots.get(config.name) !== slot;
|
|
226
|
+
if (unwanted) {
|
|
227
|
+
try {
|
|
228
|
+
await client.close();
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
this.logger.debug({ server: config.name, error }, 'Failed to close a connection that is no longer wanted');
|
|
232
|
+
}
|
|
233
|
+
this.logger.info({ server: config.name, reason: this.shuttingDown ? 'shutdown' : 'removed from config' }, 'Discarded a connection that resolved after it was no longer wanted');
|
|
234
|
+
transport = undefined;
|
|
235
|
+
throw new Error(`Server '${config.name}' is no longer configured`);
|
|
236
|
+
}
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const connection = {
|
|
121
239
|
name: config.name,
|
|
122
240
|
client,
|
|
123
241
|
transport,
|
|
124
242
|
connected: true,
|
|
125
|
-
|
|
126
|
-
|
|
243
|
+
config,
|
|
244
|
+
state: 'ready',
|
|
245
|
+
generation: ++slot.nextGeneration,
|
|
246
|
+
connectedAt: now,
|
|
247
|
+
lastUsedAt: now,
|
|
248
|
+
activeCalls: 0,
|
|
249
|
+
intentionalClose: false,
|
|
250
|
+
};
|
|
251
|
+
// Hooked only after connect() resolves: an initial failure is almost
|
|
252
|
+
// always a misconfigured command, and retrying that forever would be an
|
|
253
|
+
// unrequested behaviour change plus endless log noise.
|
|
254
|
+
//
|
|
255
|
+
// The Client's own callbacks rather than transport.onclose because
|
|
256
|
+
// Protocol.connect() captures whatever is on the transport at connect
|
|
257
|
+
// time and wraps it - assigning there afterwards is order-dependent and
|
|
258
|
+
// reaches past the class's own API.
|
|
259
|
+
client.onclose = () => this.handleDrop(slot, connection);
|
|
260
|
+
client.onerror = (error) => {
|
|
261
|
+
// Recorded, not acted on: transports report recoverable errors here
|
|
262
|
+
// too. The close that follows a fatal one is what drives the retry,
|
|
263
|
+
// and this leaves a cause behind for getServerStatuses().
|
|
264
|
+
if (slot.current === connection && connection.state === 'ready') {
|
|
265
|
+
slot.lastError = describeError(error);
|
|
266
|
+
}
|
|
267
|
+
this.logger.warn({ server: config.name, error }, 'MCP server transport error');
|
|
268
|
+
};
|
|
269
|
+
slot.reconnectAttempt = 0;
|
|
270
|
+
this.scheduleHardExpiry(slot, connection);
|
|
271
|
+
this.logger.info({ server: config.name, generation: connection.generation }, 'Successfully connected to MCP server');
|
|
272
|
+
return connection;
|
|
127
273
|
}
|
|
128
274
|
catch (error) {
|
|
129
|
-
const errorMessage = error
|
|
275
|
+
const errorMessage = describeError(error);
|
|
130
276
|
// A timed-out connect leaves the spawned process running. Tear the
|
|
131
277
|
// transport down so we don't orphan a child for the proxy's lifetime.
|
|
278
|
+
// Undefined when construction itself failed, in which case there is no
|
|
279
|
+
// process and nothing to close.
|
|
132
280
|
try {
|
|
133
|
-
await transport
|
|
281
|
+
await transport?.close();
|
|
134
282
|
}
|
|
135
283
|
catch (closeError) {
|
|
136
284
|
this.logger.debug({ server: config.name, error: closeError }, 'Failed to close transport for unsuccessful connection');
|
|
137
285
|
}
|
|
138
|
-
this.
|
|
139
|
-
name: config.name,
|
|
140
|
-
client,
|
|
141
|
-
transport,
|
|
142
|
-
connected: false,
|
|
143
|
-
lastError: errorMessage,
|
|
144
|
-
});
|
|
286
|
+
this.recordFailure(slot, errorMessage);
|
|
145
287
|
throw error;
|
|
146
288
|
}
|
|
147
289
|
}
|
|
148
290
|
/**
|
|
149
|
-
*
|
|
291
|
+
* React to a backend connection going away.
|
|
292
|
+
*
|
|
293
|
+
* `connection` identifies which generation closed: an old transport finishing
|
|
294
|
+
* its teardown after a reconnect already installed a replacement must not
|
|
295
|
+
* mark the live connection down.
|
|
150
296
|
*/
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
297
|
+
handleDrop(slot, connection) {
|
|
298
|
+
const name = slot.config.name;
|
|
299
|
+
if (connection.intentionalClose ||
|
|
300
|
+
this.shuttingDown ||
|
|
301
|
+
slot.disposed ||
|
|
302
|
+
this.slots.get(name) !== slot ||
|
|
303
|
+
slot.current !== connection ||
|
|
304
|
+
connection.state !== 'ready') {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (connection.hardExpiryTimer) {
|
|
308
|
+
clearTimeout(connection.hardExpiryTimer);
|
|
309
|
+
connection.hardExpiryTimer = undefined;
|
|
310
|
+
}
|
|
311
|
+
connection.state = 'closed';
|
|
312
|
+
connection.connected = false;
|
|
313
|
+
slot.current = undefined;
|
|
314
|
+
this.recordFailure(slot, slot.lastError ?? 'Connection closed');
|
|
315
|
+
this.logger.warn({ server: name, error: slot.lastError }, 'MCP server connection lost');
|
|
316
|
+
this.scheduleReconnect(slot);
|
|
154
317
|
}
|
|
155
318
|
/**
|
|
156
|
-
*
|
|
319
|
+
* Queue a reconnect with capped, jittered exponential backoff.
|
|
320
|
+
*
|
|
321
|
+
* Attempts are uncapped on purpose - a backend can be down for hours (a
|
|
322
|
+
* laptop asleep, a container being rebuilt) and should still come back
|
|
323
|
+
* without the operator restarting their whole MCP client. The jitter keeps
|
|
324
|
+
* several backends behind the same dead machine from retrying in lockstep.
|
|
157
325
|
*/
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
326
|
+
scheduleReconnect(slot) {
|
|
327
|
+
const name = slot.config.name;
|
|
328
|
+
// Two paths reach here - a fresh drop and a failed retry - and a second
|
|
329
|
+
// timer would double the reconnect rate while orphaning the first.
|
|
330
|
+
if (slot.reconnectTimer ||
|
|
331
|
+
slot.disposed ||
|
|
332
|
+
this.shuttingDown ||
|
|
333
|
+
this.slots.get(name) !== slot) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const attempt = slot.reconnectAttempt;
|
|
337
|
+
slot.reconnectAttempt += 1;
|
|
338
|
+
// Jitter is applied first and the cap last, so RECONNECT_MAX_MS is a real
|
|
339
|
+
// ceiling. Capping the base instead let the +20% arm push actual delays to
|
|
340
|
+
// 36s, which quietly contradicts what the constant says.
|
|
341
|
+
const backoff = this.RECONNECT_BASE_MS * 2 ** attempt;
|
|
342
|
+
const jittered = backoff * (0.8 + Math.random() * 0.4);
|
|
343
|
+
const delayMs = Math.round(Math.min(jittered, this.RECONNECT_MAX_MS));
|
|
344
|
+
const timer = setTimeout(() => {
|
|
345
|
+
slot.reconnectTimer = undefined;
|
|
346
|
+
void this.reconnect(slot);
|
|
347
|
+
}, delayMs);
|
|
348
|
+
// A pending retry must never be the reason the process cannot exit: a
|
|
349
|
+
// backend that stays down would otherwise pin the event loop open forever.
|
|
350
|
+
timer.unref?.();
|
|
351
|
+
slot.reconnectTimer = timer;
|
|
352
|
+
this.logger.info({ server: name, delayMs, attempt: attempt + 1 }, 'Scheduling MCP server reconnect');
|
|
353
|
+
}
|
|
354
|
+
async reconnect(slot) {
|
|
355
|
+
const name = slot.config.name;
|
|
356
|
+
if (slot.disposed || this.shuttingDown || this.slots.get(name) !== slot) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
await this.ensureConnection(slot);
|
|
361
|
+
this.logger.info({ server: name }, 'Reconnected to MCP server');
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
this.logger.warn({ server: name, error }, 'Reconnect attempt failed, backing off');
|
|
365
|
+
this.scheduleReconnect(slot);
|
|
366
|
+
}
|
|
162
367
|
}
|
|
163
368
|
/**
|
|
164
|
-
*
|
|
369
|
+
* Bring the live connections in line with a freshly loaded server list.
|
|
370
|
+
*
|
|
371
|
+
* Servers that vanished or changed are torn down, servers that appeared are
|
|
372
|
+
* connected, and everything untouched keeps its existing connection - an
|
|
373
|
+
* edit to one entry must not interrupt the other backends.
|
|
165
374
|
*/
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
375
|
+
async reconcile(servers, defaultTimeout, defaultInheritEnv, lifecycleDefaults = {}) {
|
|
376
|
+
const desired = new Map(servers.map((server) => {
|
|
377
|
+
const resolved = this.resolveConfig(server, defaultTimeout, defaultInheritEnv, lifecycleDefaults);
|
|
378
|
+
return [resolved.name, resolved];
|
|
379
|
+
}));
|
|
380
|
+
const removed = [];
|
|
381
|
+
const changed = [];
|
|
382
|
+
for (const [name, slot] of this.slots) {
|
|
383
|
+
const next = desired.get(name);
|
|
384
|
+
if (!next) {
|
|
385
|
+
removed.push(name);
|
|
386
|
+
}
|
|
387
|
+
else if (configFingerprint(next) !== configFingerprint(slot.config)) {
|
|
388
|
+
changed.push(name);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const added = Array.from(desired.keys()).filter((name) => !this.slots.has(name));
|
|
392
|
+
// Nothing to do on the overwhelming majority of polls; returning before the
|
|
393
|
+
// log keeps a five-second timer from filling the log with noise.
|
|
394
|
+
if (removed.length === 0 && changed.length === 0 && added.length === 0) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
this.logger.info({ removed, changed, added }, 'Applying backend server configuration change');
|
|
398
|
+
// Fully drained before a single connect starts. A changed server is a
|
|
399
|
+
// teardown *and* an add, and letting the two overlap would leave two
|
|
400
|
+
// MCPClientConnection generations racing to own the same name.
|
|
401
|
+
for (const name of [...removed, ...changed]) {
|
|
402
|
+
await this.teardownSlot(name);
|
|
403
|
+
}
|
|
404
|
+
const toConnect = new Set([...added, ...changed]);
|
|
405
|
+
await Promise.allSettled(Array.from(desired.values())
|
|
406
|
+
.filter((config) => toConnect.has(config.name))
|
|
407
|
+
.map(async (config) => {
|
|
408
|
+
const slot = {
|
|
409
|
+
config,
|
|
410
|
+
draining: new Set(),
|
|
411
|
+
nextGeneration: 0,
|
|
412
|
+
consecutiveFailures: 0,
|
|
413
|
+
recycleCount: 0,
|
|
414
|
+
authInvalidations: 0,
|
|
415
|
+
reconnectAttempt: 0,
|
|
416
|
+
disposed: false,
|
|
417
|
+
};
|
|
418
|
+
this.slots.set(config.name, slot);
|
|
419
|
+
try {
|
|
420
|
+
await this.ensureConnection(slot);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
this.logger.error({ server: config.name, error }, 'Failed to connect to MCP server');
|
|
424
|
+
}
|
|
171
425
|
}));
|
|
172
426
|
}
|
|
173
427
|
/**
|
|
174
|
-
*
|
|
428
|
+
* Close a connection the operator has removed or replaced.
|
|
429
|
+
*
|
|
430
|
+
* Order matters: the queued retry dies first and the close is claimed as
|
|
431
|
+
* ours *before* close() runs, so handleDrop() cannot resurrect a server that
|
|
432
|
+
* was deliberately taken out of servers.json.
|
|
175
433
|
*/
|
|
176
|
-
|
|
177
|
-
|
|
434
|
+
async teardownSlot(name) {
|
|
435
|
+
const slot = this.slots.get(name);
|
|
436
|
+
if (!slot) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
this.slots.delete(name);
|
|
440
|
+
slot.disposed = true;
|
|
441
|
+
if (slot.reconnectTimer) {
|
|
442
|
+
clearTimeout(slot.reconnectTimer);
|
|
443
|
+
slot.reconnectTimer = undefined;
|
|
444
|
+
}
|
|
445
|
+
const connections = new Set(slot.draining);
|
|
446
|
+
if (slot.current) {
|
|
447
|
+
connections.add(slot.current);
|
|
448
|
+
slot.current = undefined;
|
|
449
|
+
}
|
|
450
|
+
await Promise.allSettled(Array.from(connections).map(async (connection) => {
|
|
451
|
+
connection.intentionalClose = true;
|
|
452
|
+
connection.connected = false;
|
|
453
|
+
connection.state = 'closed';
|
|
454
|
+
if (connection.hardExpiryTimer) {
|
|
455
|
+
clearTimeout(connection.hardExpiryTimer);
|
|
456
|
+
connection.hardExpiryTimer = undefined;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
await connection.client.close();
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
this.logger.debug({ server: name, error }, 'Error closing removed MCP server');
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
slot.draining.delete(connection);
|
|
466
|
+
}
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
lifecycleDefaultsFromConfig(config) {
|
|
470
|
+
return {
|
|
471
|
+
softMaxConnectionAgeSeconds: config.softMaxConnectionAgeSeconds,
|
|
472
|
+
hardMaxConnectionAgeSeconds: config.hardMaxConnectionAgeSeconds,
|
|
473
|
+
authErrorPatterns: config.authErrorPatterns,
|
|
474
|
+
authRetryTools: config.authRetryTools,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
cancelReconnect(slot) {
|
|
478
|
+
if (slot.reconnectTimer) {
|
|
479
|
+
clearTimeout(slot.reconnectTimer);
|
|
480
|
+
slot.reconnectTimer = undefined;
|
|
481
|
+
}
|
|
178
482
|
}
|
|
179
483
|
/**
|
|
180
|
-
*
|
|
484
|
+
* Poll the config for server list changes and apply them live.
|
|
485
|
+
*
|
|
486
|
+
* Polling rather than fs.watch: watchers fire duplicate events and stop
|
|
487
|
+
* working entirely once a file is replaced by write-temp-then-rename, which
|
|
488
|
+
* is how most editors and jq-style tools save. Two stats every few seconds
|
|
489
|
+
* are cheaper than the bug reports that would follow.
|
|
490
|
+
*
|
|
491
|
+
* `loadConfig` is injected rather than imported so this stays testable
|
|
492
|
+
* without fixture files, matching the loader injection in StatsService.
|
|
181
493
|
*/
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
494
|
+
startConfigWatch(loadConfig, intervalMs = 5000, onConfigLoaded) {
|
|
495
|
+
// A second watcher would double the poll rate and leak the first interval.
|
|
496
|
+
if (this.configWatchTimer) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
this.configWatchTimer = setInterval(() => {
|
|
500
|
+
let config;
|
|
185
501
|
try {
|
|
186
|
-
|
|
502
|
+
config = loadConfig();
|
|
187
503
|
}
|
|
188
504
|
catch (error) {
|
|
189
|
-
|
|
505
|
+
// A half-written servers.json is invalid JSON for a few milliseconds.
|
|
506
|
+
// That is an editor mid-save, not a reason to stop watching.
|
|
507
|
+
this.logger.warn({ error }, 'Config reload failed, keeping the current backend servers');
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (!config) {
|
|
511
|
+
return;
|
|
190
512
|
}
|
|
513
|
+
// Settings that live outside this class - compression patterns, the
|
|
514
|
+
// uncompressed-tool fallback - are applied by the owner. Without this a
|
|
515
|
+
// proxy started before servers.json existed would connect the servers it
|
|
516
|
+
// later described but ignore the noCompressTools in the same file.
|
|
517
|
+
try {
|
|
518
|
+
onConfigLoaded?.(config);
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
this.logger.warn({ error }, 'Config reload hook failed');
|
|
522
|
+
}
|
|
523
|
+
void this.reconcile(config.servers.filter((server) => server.enabled !== false), config.defaultTimeout, config.inheritEnv, this.lifecycleDefaultsFromConfig(config)).catch((error) => {
|
|
524
|
+
this.logger.error({ error }, 'Failed to apply backend server configuration');
|
|
525
|
+
});
|
|
526
|
+
}, intervalMs);
|
|
527
|
+
// Housekeeping must never be what keeps the process alive.
|
|
528
|
+
this.configWatchTimer.unref?.();
|
|
529
|
+
this.logger.info({ intervalMs }, 'Watching configuration for server changes');
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Single-flight connection creation. Concurrent callers share this promise.
|
|
533
|
+
*/
|
|
534
|
+
async ensureConnection(slot) {
|
|
535
|
+
if (this.shuttingDown) {
|
|
536
|
+
throw new Error('MCP client manager is shutting down');
|
|
537
|
+
}
|
|
538
|
+
if (slot.current?.state === 'ready') {
|
|
539
|
+
return slot.current;
|
|
540
|
+
}
|
|
541
|
+
if (slot.connectPromise) {
|
|
542
|
+
return slot.connectPromise;
|
|
543
|
+
}
|
|
544
|
+
const connectPromise = this.createConnection(slot).then(async (connection) => {
|
|
545
|
+
if (this.shuttingDown || slot.disposed || this.slots.get(slot.config.name) !== slot) {
|
|
546
|
+
connection.intentionalClose = true;
|
|
547
|
+
await this.closeConnection(slot, connection);
|
|
548
|
+
throw new Error('MCP client manager no longer wants this connection');
|
|
549
|
+
}
|
|
550
|
+
slot.current = connection;
|
|
551
|
+
return connection;
|
|
191
552
|
});
|
|
553
|
+
slot.connectPromise = connectPromise;
|
|
554
|
+
try {
|
|
555
|
+
return await connectPromise;
|
|
556
|
+
}
|
|
557
|
+
finally {
|
|
558
|
+
if (slot.connectPromise === connectPromise) {
|
|
559
|
+
slot.connectPromise = undefined;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
scheduleHardExpiry(slot, connection) {
|
|
564
|
+
const hardAgeSeconds = connection.config.hardMaxConnectionAgeSeconds;
|
|
565
|
+
if (hardAgeSeconds <= 0)
|
|
566
|
+
return;
|
|
567
|
+
connection.hardExpiryTimer = setTimeout(() => {
|
|
568
|
+
this.beginDrain(slot, connection, 'hard-max-age');
|
|
569
|
+
}, hardAgeSeconds * 1000);
|
|
570
|
+
connection.hardExpiryTimer.unref?.();
|
|
571
|
+
}
|
|
572
|
+
isSoftExpired(connection) {
|
|
573
|
+
const softAgeSeconds = connection.config.softMaxConnectionAgeSeconds;
|
|
574
|
+
return softAgeSeconds > 0 && Date.now() - connection.connectedAt >= softAgeSeconds * 1000;
|
|
575
|
+
}
|
|
576
|
+
beginDrain(slot, connection, reason) {
|
|
577
|
+
if (connection.state !== 'ready')
|
|
578
|
+
return;
|
|
579
|
+
connection.state = 'draining';
|
|
580
|
+
if (connection.hardExpiryTimer) {
|
|
581
|
+
clearTimeout(connection.hardExpiryTimer);
|
|
582
|
+
connection.hardExpiryTimer = undefined;
|
|
583
|
+
}
|
|
584
|
+
if (slot.current === connection) {
|
|
585
|
+
slot.current = undefined;
|
|
586
|
+
}
|
|
587
|
+
slot.draining.add(connection);
|
|
588
|
+
if (reason !== 'shutdown') {
|
|
589
|
+
slot.recycleCount += 1;
|
|
590
|
+
}
|
|
591
|
+
if (reason === 'auth-error') {
|
|
592
|
+
slot.authInvalidations += 1;
|
|
593
|
+
}
|
|
594
|
+
this.logger.info({
|
|
595
|
+
server: slot.config.name,
|
|
596
|
+
generation: connection.generation,
|
|
597
|
+
reason,
|
|
598
|
+
ageMs: Date.now() - connection.connectedAt,
|
|
599
|
+
activeCalls: connection.activeCalls,
|
|
600
|
+
}, 'Draining MCP connection');
|
|
601
|
+
if (connection.activeCalls === 0) {
|
|
602
|
+
void this.closeConnection(slot, connection);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
async closeConnection(slot, connection) {
|
|
606
|
+
if (connection.closePromise) {
|
|
607
|
+
return connection.closePromise;
|
|
608
|
+
}
|
|
609
|
+
if (connection.hardExpiryTimer) {
|
|
610
|
+
clearTimeout(connection.hardExpiryTimer);
|
|
611
|
+
connection.hardExpiryTimer = undefined;
|
|
612
|
+
}
|
|
613
|
+
connection.intentionalClose = true;
|
|
614
|
+
connection.state = 'closed';
|
|
615
|
+
connection.connected = false;
|
|
616
|
+
connection.closePromise = (async () => {
|
|
617
|
+
try {
|
|
618
|
+
await connection.client.close();
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
this.recordFailure(slot, error);
|
|
622
|
+
this.logger.error({
|
|
623
|
+
server: slot.config.name,
|
|
624
|
+
generation: connection.generation,
|
|
625
|
+
error,
|
|
626
|
+
}, 'Failed to close MCP client; closing transport directly');
|
|
627
|
+
try {
|
|
628
|
+
await connection.transport?.close();
|
|
629
|
+
}
|
|
630
|
+
catch (transportError) {
|
|
631
|
+
this.logger.error({
|
|
632
|
+
server: slot.config.name,
|
|
633
|
+
generation: connection.generation,
|
|
634
|
+
error: transportError,
|
|
635
|
+
}, 'Failed to close MCP transport');
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
finally {
|
|
639
|
+
slot.draining.delete(connection);
|
|
640
|
+
if (slot.current === connection) {
|
|
641
|
+
slot.current = undefined;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
})();
|
|
645
|
+
return connection.closePromise;
|
|
646
|
+
}
|
|
647
|
+
async acquireConnection(serverName) {
|
|
648
|
+
const slot = this.slots.get(serverName);
|
|
649
|
+
if (!slot) {
|
|
650
|
+
throw new Error(`Server '${serverName}' is not configured`);
|
|
651
|
+
}
|
|
652
|
+
let connection = slot.current;
|
|
653
|
+
if (connection?.state === 'ready' && this.isSoftExpired(connection)) {
|
|
654
|
+
this.beginDrain(slot, connection, 'soft-max-age');
|
|
655
|
+
connection = undefined;
|
|
656
|
+
}
|
|
657
|
+
if (!connection || connection.state !== 'ready') {
|
|
658
|
+
connection = await this.ensureConnection(slot);
|
|
659
|
+
}
|
|
660
|
+
if (connection.state !== 'ready') {
|
|
661
|
+
return this.acquireConnection(serverName);
|
|
662
|
+
}
|
|
663
|
+
connection.activeCalls += 1;
|
|
664
|
+
connection.lastUsedAt = Date.now();
|
|
665
|
+
slot.lastUsedAt = connection.lastUsedAt;
|
|
666
|
+
return { slot, connection };
|
|
667
|
+
}
|
|
668
|
+
async releaseConnection(slot, connection) {
|
|
669
|
+
connection.activeCalls = Math.max(0, connection.activeCalls - 1);
|
|
670
|
+
connection.lastUsedAt = Date.now();
|
|
671
|
+
slot.lastUsedAt = connection.lastUsedAt;
|
|
672
|
+
if (connection.state === 'draining' && connection.activeCalls === 0) {
|
|
673
|
+
await this.closeConnection(slot, connection);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
recordSuccess(slot) {
|
|
677
|
+
slot.lastSuccessAt = Date.now();
|
|
678
|
+
slot.lastError = undefined;
|
|
679
|
+
slot.consecutiveFailures = 0;
|
|
680
|
+
}
|
|
681
|
+
recordFailure(slot, error) {
|
|
682
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
683
|
+
slot.lastError = message;
|
|
684
|
+
slot.consecutiveFailures += 1;
|
|
685
|
+
if (slot.consecutiveFailures === 3 ||
|
|
686
|
+
(slot.consecutiveFailures > 3 && slot.consecutiveFailures % 5 === 0)) {
|
|
687
|
+
this.logger.warn({
|
|
688
|
+
server: slot.config.name,
|
|
689
|
+
consecutiveFailures: slot.consecutiveFailures,
|
|
690
|
+
lastSuccessAt: slot.lastSuccessAt,
|
|
691
|
+
error: message,
|
|
692
|
+
}, 'MCP server has repeated failures');
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Execute work under a lease. Invalidating the context drains this exact
|
|
697
|
+
* generation, so concurrent recovery cannot accidentally close a newer one.
|
|
698
|
+
*/
|
|
699
|
+
async withClient(serverName, operation) {
|
|
700
|
+
const { slot, connection } = await this.acquireConnection(serverName);
|
|
701
|
+
slot.lastAttemptAt = Date.now();
|
|
702
|
+
let failureReason;
|
|
703
|
+
try {
|
|
704
|
+
const result = await operation({
|
|
705
|
+
client: connection.client,
|
|
706
|
+
generation: connection.generation,
|
|
707
|
+
markFailure: (reason) => {
|
|
708
|
+
failureReason ??= reason;
|
|
709
|
+
},
|
|
710
|
+
invalidate: (reason) => {
|
|
711
|
+
failureReason ??= reason;
|
|
712
|
+
this.beginDrain(slot, connection, reason);
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
if (failureReason) {
|
|
716
|
+
this.recordFailure(slot, failureReason);
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
this.recordSuccess(slot);
|
|
720
|
+
}
|
|
721
|
+
return result;
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
this.recordFailure(slot, error);
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
await this.releaseConnection(slot, connection);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
getConfiguredServerNames() {
|
|
732
|
+
return Array.from(this.slots.keys());
|
|
733
|
+
}
|
|
734
|
+
getAuthRecoveryPolicy(serverName) {
|
|
735
|
+
const slot = this.slots.get(serverName);
|
|
736
|
+
return {
|
|
737
|
+
authErrorPatterns: [...(slot?.config.authErrorPatterns ?? [])],
|
|
738
|
+
authRetryTools: [...(slot?.config.authRetryTools ?? [])],
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Compatibility accessors. Production operations should use withClient so
|
|
743
|
+
* lifecycle age, active leases, and health are tracked.
|
|
744
|
+
*/
|
|
745
|
+
getClient(serverName) {
|
|
746
|
+
const connection = this.slots.get(serverName)?.current;
|
|
747
|
+
return connection?.state === 'ready' ? connection.client : undefined;
|
|
748
|
+
}
|
|
749
|
+
getConnectedClients() {
|
|
750
|
+
return Array.from(this.slots.entries()).flatMap(([name, slot]) => slot.current?.state === 'ready' ? [{ name, client: slot.current.client }] : []);
|
|
751
|
+
}
|
|
752
|
+
getServerStatuses() {
|
|
753
|
+
const now = Date.now();
|
|
754
|
+
return Array.from(this.slots.entries()).map(([name, slot]) => {
|
|
755
|
+
const current = slot.current?.state === 'ready' ? slot.current : undefined;
|
|
756
|
+
const activeCalls = (current?.activeCalls ?? 0) +
|
|
757
|
+
Array.from(slot.draining).reduce((total, connection) => total + connection.activeCalls, 0);
|
|
758
|
+
const state = current
|
|
759
|
+
? 'ready'
|
|
760
|
+
: slot.connectPromise
|
|
761
|
+
? 'starting'
|
|
762
|
+
: slot.draining.size > 0
|
|
763
|
+
? 'draining'
|
|
764
|
+
: slot.lastError && slot.consecutiveFailures > 0
|
|
765
|
+
? 'failed'
|
|
766
|
+
: 'closed';
|
|
767
|
+
return {
|
|
768
|
+
name,
|
|
769
|
+
connected: current !== undefined,
|
|
770
|
+
state,
|
|
771
|
+
lastError: slot.lastError,
|
|
772
|
+
activeCalls,
|
|
773
|
+
drainingConnections: slot.draining.size,
|
|
774
|
+
generation: current?.generation ?? (slot.nextGeneration || undefined),
|
|
775
|
+
connectedAt: current?.connectedAt,
|
|
776
|
+
lastUsedAt: slot.lastUsedAt,
|
|
777
|
+
lastAttemptAt: slot.lastAttemptAt,
|
|
778
|
+
lastSuccessAt: slot.lastSuccessAt,
|
|
779
|
+
connectionAgeSeconds: current ? Math.floor((now - current.connectedAt) / 1000) : undefined,
|
|
780
|
+
softMaxConnectionAgeSeconds: slot.config.softMaxConnectionAgeSeconds,
|
|
781
|
+
hardMaxConnectionAgeSeconds: slot.config.hardMaxConnectionAgeSeconds,
|
|
782
|
+
recycleCount: slot.recycleCount,
|
|
783
|
+
authInvalidations: slot.authInvalidations,
|
|
784
|
+
consecutiveFailures: slot.consecutiveFailures,
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
hasConnectedServers() {
|
|
789
|
+
return Array.from(this.slots.values()).some((slot) => slot.current?.state === 'ready');
|
|
790
|
+
}
|
|
791
|
+
async disconnectAll() {
|
|
792
|
+
this.logger.info('Disconnecting from all MCP servers');
|
|
793
|
+
this.shuttingDown = true;
|
|
794
|
+
if (this.configWatchTimer) {
|
|
795
|
+
clearInterval(this.configWatchTimer);
|
|
796
|
+
this.configWatchTimer = undefined;
|
|
797
|
+
}
|
|
798
|
+
const disconnectPromises = [];
|
|
799
|
+
const slots = Array.from(this.slots.values());
|
|
800
|
+
this.slots.clear();
|
|
801
|
+
for (const slot of slots) {
|
|
802
|
+
slot.disposed = true;
|
|
803
|
+
this.cancelReconnect(slot);
|
|
804
|
+
const connections = new Set(slot.draining);
|
|
805
|
+
if (slot.current) {
|
|
806
|
+
connections.add(slot.current);
|
|
807
|
+
slot.current = undefined;
|
|
808
|
+
}
|
|
809
|
+
for (const connection of connections) {
|
|
810
|
+
if (connection.state === 'ready') {
|
|
811
|
+
connection.state = 'draining';
|
|
812
|
+
}
|
|
813
|
+
disconnectPromises.push(this.closeConnection(slot, connection));
|
|
814
|
+
}
|
|
815
|
+
}
|
|
192
816
|
await Promise.allSettled(disconnectPromises);
|
|
193
|
-
this.connections.clear();
|
|
194
817
|
this.logger.info('All MCP servers disconnected');
|
|
195
818
|
}
|
|
196
819
|
}
|