mcp-compression-proxy 1.0.2 → 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.
@@ -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
- * Manages connections to multiple MCP servers
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
- connections = new Map();
66
+ slots = new Map();
9
67
  logger;
10
- DEFAULT_TIMEOUT_MS = 30000; // 30 seconds default timeout
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. By default
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
- * Initialize and connect to all configured MCP servers
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
- const connectionPromises = serversWithTimeout.map(async (config) => {
83
- try {
84
- await this.connectToServer(config);
85
- }
86
- catch (error) {
87
- this.logger.error({ server: config.name, error }, 'Failed to connect to MCP server');
88
- }
89
- });
90
- await Promise.allSettled(connectionPromises);
91
- const connectedCount = Array.from(this.connections.values()).filter((c) => c.connected).length;
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
- * Connect to a single MCP server with timeout
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
- async connectToServer(config) {
98
- // Use server-specific timeout or default (convert seconds to milliseconds)
99
- const timeoutMs = config.timeout
100
- ? config.timeout * 1000
101
- : this.DEFAULT_TIMEOUT_MS;
102
- this.logger.info({ server: config.name, timeoutMs }, 'Connecting to MCP server');
103
- const transport = new StdioClientTransport({
104
- command: config.command,
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.connections.set(config.name, {
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
- this.logger.info({ server: config.name }, 'Successfully connected to MCP server');
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 instanceof Error ? error.message : 'Unknown 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.close();
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.connections.set(config.name, {
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
- * Get a connected client by server name
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
- getClient(serverName) {
152
- const connection = this.connections.get(serverName);
153
- return connection?.connected ? connection.client : undefined;
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
- * Get all connected clients
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
- getConnectedClients() {
159
- return Array.from(this.connections.values())
160
- .filter((conn) => conn.connected)
161
- .map((conn) => ({ name: conn.name, client: conn.client }));
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
- * Get status of all servers
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
- getServerStatuses() {
167
- return Array.from(this.connections.values()).map((conn) => ({
168
- name: conn.name,
169
- connected: conn.connected,
170
- lastError: conn.lastError,
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
- * Check if at least one server is connected
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
- hasConnectedServers() {
177
- return Array.from(this.connections.values()).some((conn) => conn.connected);
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
- * Disconnect from all servers
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
- async disconnectAll() {
183
- this.logger.info('Disconnecting from all MCP servers');
184
- const disconnectPromises = Array.from(this.connections.values()).map(async (conn) => {
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
- await conn.client.close();
502
+ config = loadConfig();
187
503
  }
188
504
  catch (error) {
189
- this.logger.error({ server: conn.name, error }, 'Error disconnecting from server');
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
  }