lazy-mcp 2.2.7 → 2.3.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,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ServerManager = void 0;
3
+ exports.ServerManager = exports.DEFAULT_REQUEST_TIMEOUT_MS = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const crypto_1 = require("crypto");
6
6
  const types_1 = require("./types");
@@ -9,6 +9,7 @@ const error_diagnostics_1 = require("./error-diagnostics");
9
9
  const oauth_manager_1 = require("./oauth-manager");
10
10
  const version_1 = require("./version");
11
11
  const eventsource_parser_1 = require("eventsource-parser");
12
+ const logger_1 = require("./logger");
12
13
  /** Canonical header name for the MCP session token (lowercase — HTTP headers are case-insensitive). */
13
14
  const MCP_SESSION_HEADER = "mcp-session-id";
14
15
  const DEFAULT_CACHE_CONFIG = {
@@ -16,7 +17,8 @@ const DEFAULT_CACHE_CONFIG = {
16
17
  ttl: 0, // No expiration
17
18
  maxAccessCount: 0, // No access cap
18
19
  };
19
- const REQUEST_TIMEOUT_MS = 10000;
20
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
21
+ exports.DEFAULT_REQUEST_TIMEOUT_MS = DEFAULT_REQUEST_TIMEOUT_MS;
20
22
  const DEFAULT_HEALTH_MONITOR_CONFIG = {
21
23
  enabled: true,
22
24
  interval: 30000, // 30 seconds
@@ -24,7 +26,7 @@ const DEFAULT_HEALTH_MONITOR_CONFIG = {
24
26
  idleTimeout: 300000, // 5 minutes — sleep after this much inactivity
25
27
  };
26
28
  class RemoteMCPServer {
27
- constructor(serverUrl, serverName, headers = {}, oauthManager) {
29
+ constructor(serverUrl, serverName, headers = {}, oauthManager, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
28
30
  /** Session ID from `Mcp-Session-Id` header, populated after `initialize` handshake. */
29
31
  this.sessionId = null;
30
32
  /** True once the MCP handshake (initialize + notifications/initialized) has completed. */
@@ -33,6 +35,7 @@ class RemoteMCPServer {
33
35
  this.initializing = null;
34
36
  this.serverUrl = serverUrl;
35
37
  this.serverName = serverName;
38
+ this.timeoutMs = timeoutMs;
36
39
  this.headers = {
37
40
  "Content-Type": "application/json",
38
41
  ...headers,
@@ -40,26 +43,58 @@ class RemoteMCPServer {
40
43
  // Every remote server gets an OAuthManager — config overrides defaults if provided
41
44
  this.oauthManager = oauthManager ?? new oauth_manager_1.OAuthManager(serverName, serverUrl, {});
42
45
  }
46
+ async withRequestTimeout(label, fn) {
47
+ const controller = new AbortController();
48
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
49
+ try {
50
+ return await fn(controller.signal);
51
+ }
52
+ catch (err) {
53
+ if (err?.name === "AbortError") {
54
+ throw new types_1.MCPException(types_1.MCPErrorCode.SERVER_TIMEOUT, `Request timed out (${this.timeoutMs / 1000}s) for server '${this.serverName}'`, this.serverName);
55
+ }
56
+ throw err;
57
+ }
58
+ finally {
59
+ clearTimeout(timeoutId);
60
+ }
61
+ }
43
62
  /**
44
- * Parse SSE stream and extract JSON-RPC response matching the request ID
63
+ * Parse SSE stream and extract JSON-RPC response matching the request ID.
64
+ * The signal is assumed to be controlled by withRequestTimeout() — when the
65
+ * timeout fires the outer promise is guaranteed to be rejected.
45
66
  */
46
- async parseSSEStream(stream, requestId) {
67
+ async parseSSEStream(stream, requestId, signal) {
47
68
  return new Promise((resolve, reject) => {
48
69
  const reader = stream.getReader();
49
70
  const decoder = new TextDecoder();
50
71
  let buffer = "";
51
- let resolved = false; // Track if promise is settled
72
+ let settled = false;
73
+ const cleanup = () => {
74
+ signal.removeEventListener("abort", onAbort);
75
+ };
76
+ const rejectAbort = () => {
77
+ if (settled)
78
+ return;
79
+ settled = true;
80
+ cleanup();
81
+ reader.cancel().catch(() => { });
82
+ const err = new Error("The operation was aborted.");
83
+ err.name = "AbortError";
84
+ reject(err);
85
+ };
86
+ const onAbort = () => rejectAbort();
87
+ signal.addEventListener("abort", onAbort, { once: true });
52
88
  const parser = (0, eventsource_parser_1.createParser)({
53
89
  onEvent: (event) => {
54
- if (!event.data || resolved)
90
+ if (!event.data || settled)
55
91
  return;
56
92
  try {
57
93
  const message = JSON.parse(event.data);
58
- // Check if this is our response
59
94
  if (message.jsonrpc === "2.0" && message.id === requestId) {
60
- resolved = true;
61
- clearTimeout(timeoutId);
62
- reader.cancel(); // Stop reading the stream
95
+ settled = true;
96
+ cleanup();
97
+ reader.cancel().catch(() => { });
63
98
  if (message.error) {
64
99
  reject(new Error(`MCP Error: ${message.error.message}`));
65
100
  }
@@ -73,10 +108,10 @@ class RemoteMCPServer {
73
108
  }
74
109
  },
75
110
  onError: (error) => {
76
- if (!resolved) {
77
- resolved = true;
78
- clearTimeout(timeoutId);
79
- reader.cancel();
111
+ if (!settled) {
112
+ settled = true;
113
+ cleanup();
114
+ reader.cancel().catch(() => { });
80
115
  reject(new Error(`SSE parse error: ${error}`));
81
116
  }
82
117
  },
@@ -86,37 +121,30 @@ class RemoteMCPServer {
86
121
  while (true) {
87
122
  const { done, value } = await reader.read();
88
123
  if (done) {
89
- if (!resolved) {
90
- resolved = true;
91
- clearTimeout(timeoutId);
124
+ if (!settled) {
125
+ settled = true;
126
+ cleanup();
92
127
  reject(new Error(`SSE stream ended without receiving response for request ${requestId}`));
93
128
  }
94
129
  break;
95
130
  }
96
- // Decode chunk and add to buffer
97
131
  buffer += decoder.decode(value, { stream: true });
98
- // Feed to parser
99
132
  parser.feed(buffer);
100
133
  buffer = "";
101
134
  }
102
135
  }
103
136
  catch (err) {
104
- if (!resolved) {
105
- resolved = true;
106
- clearTimeout(timeoutId);
137
+ if (!settled) {
138
+ settled = true;
139
+ cleanup();
107
140
  reject(err);
108
141
  }
109
142
  }
143
+ finally {
144
+ cleanup();
145
+ }
110
146
  };
111
147
  processStream();
112
- // Timeout after 30 seconds
113
- const timeoutId = setTimeout(() => {
114
- if (!resolved) {
115
- resolved = true;
116
- reader.cancel();
117
- reject(new Error(`SSE request timeout for request ${requestId}`));
118
- }
119
- }, 30000);
120
148
  });
121
149
  }
122
150
  async ensureInitialized() {
@@ -140,7 +168,7 @@ class RemoteMCPServer {
140
168
  * 2. Parse `mcp-session-id` from response headers (if present)
141
169
  * 3. POST `notifications/initialized` (with session header if we have one)
142
170
  *
143
- * Both fetch calls are guarded by REQUEST_TIMEOUT_MS via AbortController.
171
+ * Both phases stay under deadline until fully complete (body read included).
144
172
  *
145
173
  * This follows the MCP spec for Streamable HTTP transport:
146
174
  * https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
@@ -162,42 +190,27 @@ class RemoteMCPServer {
162
190
  ...oauthHeaders,
163
191
  Accept: "application/json, text/event-stream",
164
192
  };
165
- // Guard both fetch calls with REQUEST_TIMEOUT_MS
166
- const initAbort = new AbortController();
167
- const initTimeout = setTimeout(() => initAbort.abort(), REQUEST_TIMEOUT_MS);
168
- let response;
169
- try {
170
- response = await fetch(this.serverUrl, {
193
+ const { sessionId, initResult } = await this.withRequestTimeout("initialize", async (signal) => {
194
+ const response = await fetch(this.serverUrl, {
171
195
  method: "POST",
172
196
  headers: requestHeaders,
173
197
  body: JSON.stringify(initRequest),
174
- signal: initAbort.signal,
198
+ signal,
175
199
  });
176
- }
177
- catch (err) {
178
- if (err?.name === "AbortError") {
179
- throw new types_1.MCPException(types_1.MCPErrorCode.SERVER_TIMEOUT, `Initialize timed out (${REQUEST_TIMEOUT_MS / 1000}s) for server '${this.serverName}'`, this.serverName, undefined, `The remote server did not respond within ${REQUEST_TIMEOUT_MS / 1000}s. Check that the server is running and reachable.`);
200
+ if (!response.ok) {
201
+ await this.handleHttpError(response, oauthHeaders);
180
202
  }
181
- throw err;
182
- }
183
- finally {
184
- clearTimeout(initTimeout);
185
- }
186
- if (!response.ok) {
187
- await this.handleHttpError(response, oauthHeaders);
188
- }
189
- // Extract session ID from response headers (may be absent for stateless servers).
190
- // Use the canonical lowercase name; fetch Headers are case-insensitive on get().
191
- const sessionId = response.headers.get(MCP_SESSION_HEADER);
203
+ return {
204
+ sessionId: response.headers.get(MCP_SESSION_HEADER),
205
+ initResult: await this.parseOkResponse(response, initRequest.id, signal),
206
+ };
207
+ });
192
208
  if (sessionId) {
193
209
  this.sessionId = sessionId;
194
210
  }
195
- // Parse the initialize result (validates the server understood the handshake)
196
- const initResult = await this.parseOkResponse(response, initRequest.id);
197
211
  if (!initResult?.protocolVersion) {
198
212
  throw new Error(`Server '${this.serverName}' returned invalid initialize response`);
199
213
  }
200
- // Send notifications/initialized (with session header if we have one)
201
214
  const initializedNotification = {
202
215
  jsonrpc: "2.0",
203
216
  method: "notifications/initialized",
@@ -211,34 +224,18 @@ class RemoteMCPServer {
211
224
  if (this.sessionId) {
212
225
  notifHeaders[MCP_SESSION_HEADER] = this.sessionId;
213
226
  }
214
- const notifAbort = new AbortController();
215
- const notifTimeout = setTimeout(() => notifAbort.abort(), REQUEST_TIMEOUT_MS);
216
- let notifResponse;
217
- try {
218
- notifResponse = await fetch(this.serverUrl, {
227
+ await this.withRequestTimeout("notifications/initialized", async (signal) => {
228
+ const notifResponse = await fetch(this.serverUrl, {
219
229
  method: "POST",
220
230
  headers: notifHeaders,
221
231
  body: JSON.stringify(initializedNotification),
222
- signal: notifAbort.signal,
232
+ signal,
223
233
  });
224
- }
225
- catch (err) {
226
- if (err?.name === "AbortError") {
227
- throw new types_1.MCPException(types_1.MCPErrorCode.SERVER_TIMEOUT, `notifications/initialized timed out (${REQUEST_TIMEOUT_MS / 1000}s) for server '${this.serverName}'`, this.serverName, undefined, `The remote server did not respond within ${REQUEST_TIMEOUT_MS / 1000}s during the MCP handshake.`);
234
+ if (!notifResponse.ok) {
235
+ const body = await notifResponse.text();
236
+ throw new types_1.MCPException(types_1.MCPErrorCode.SERVER_UNREACHABLE, `MCP handshake failed: server '${this.serverName}' rejected notifications/initialized with HTTP ${notifResponse.status}`, this.serverName, body.slice(0, 200) || undefined, `Ensure the server implements the MCP Streamable HTTP transport correctly. It must accept POST notifications/initialized and return a 2xx status.`);
228
237
  }
229
- throw err;
230
- }
231
- finally {
232
- clearTimeout(notifTimeout);
233
- }
234
- // Per spec: server MUST return 202 Accepted for notifications (no request ID).
235
- // Some servers may return 200 — accept any 2xx.
236
- // A non-2xx response means the handshake is incomplete; fail loudly so the
237
- // caller sees a clear error rather than a cryptic failure on the next request.
238
- if (!notifResponse.ok) {
239
- const body = await notifResponse.text().catch(() => "");
240
- throw new types_1.MCPException(types_1.MCPErrorCode.SERVER_UNREACHABLE, `MCP handshake failed: server '${this.serverName}' rejected notifications/initialized with HTTP ${notifResponse.status}`, this.serverName, body.slice(0, 200) || undefined, `Ensure the server implements the MCP Streamable HTTP transport correctly. It must accept POST notifications/initialized and return a 2xx status.`);
241
- }
238
+ });
242
239
  this.initialized = true;
243
240
  }
244
241
  /**
@@ -254,7 +251,18 @@ class RemoteMCPServer {
254
251
  throw new types_1.MCPException(types_1.MCPErrorCode.AUTH_REQUIRED, `Authentication required for '${this.serverName}'`, this.serverName, undefined, `Open this URL in your browser to authorize:\n\n${authUrl}\n\nlazy-mcp is listening on ${this.oauthManager.redirectUrl} and will capture the token automatically. Once authorized, retry your original command.`);
255
252
  }
256
253
  const errorText = await response.text();
257
- console.error(`Remote server error: HTTP ${response.status} ${response.statusText} - ${errorText}`);
254
+ const logger = (0, logger_1.getLogger)();
255
+ const preparedBody = logger.prepareBody(errorText);
256
+ logger.error("Remote server error", {
257
+ event: "remote_server_error",
258
+ server: this.serverName,
259
+ httpStatus: response.status,
260
+ httpStatusText: response.statusText,
261
+ responseBody: preparedBody.body,
262
+ responseBodyBytes: preparedBody.bodyBytes,
263
+ responseBodyLoggedBytes: preparedBody.bodyLoggedBytes,
264
+ responseBodyTruncated: preparedBody.bodyTruncated,
265
+ });
258
266
  const { code, diagnostic } = error_diagnostics_1.ErrorDiagnostics.analyzeHttpError(response.status, this.serverName);
259
267
  throw new types_1.MCPException(code, `HTTP ${response.status}: ${response.statusText}`, this.serverName, errorText.slice(0, 200), diagnostic);
260
268
  }
@@ -265,7 +273,6 @@ class RemoteMCPServer {
265
273
  }
266
274
  throw new Error("Only HTTP and WebSocket URLs are supported");
267
275
  }
268
- // Ensure MCP handshake has been performed (idempotent after first call)
269
276
  await this.ensureInitialized();
270
277
  const request = {
271
278
  jsonrpc: "2.0",
@@ -273,51 +280,48 @@ class RemoteMCPServer {
273
280
  method,
274
281
  ...(params && { params }),
275
282
  };
276
- // Inject OAuth headers if we have a valid stored token
277
283
  const oauthHeaders = this.oauthManager.getStoredAuthHeaders() ?? {};
278
284
  const requestHeaders = {
279
285
  ...this.headers,
280
286
  ...oauthHeaders,
281
287
  Accept: "application/json, text/event-stream",
282
288
  };
283
- // Include session ID in all requests after initialization
284
289
  if (this.sessionId) {
285
290
  requestHeaders[MCP_SESSION_HEADER] = this.sessionId;
286
291
  }
287
- const response = await fetch(this.serverUrl, {
288
- method: "POST",
289
- headers: requestHeaders,
290
- body: JSON.stringify(request),
291
- });
292
- if (!response.ok) {
293
- // Session expired — reset and retry once with a fresh handshake.
294
- // Retry regardless of whether we have a sessionId: some servers use a
295
- // stateful handshake without returning an Mcp-Session-Id header, and can
296
- // still expire the implicit server-side session.
297
- // _isRetry guards against infinite loops.
298
- if (response.status === 404 && !_isRetry) {
299
- console.error(`Session expired for server '${this.serverName}', re-initializing...`);
300
- this.initialized = false;
301
- this.sessionId = null;
302
- return this.makeRequest(method, params, true);
292
+ return this.withRequestTimeout(`request '${method}'`, async (signal) => {
293
+ const response = await fetch(this.serverUrl, {
294
+ method: "POST",
295
+ headers: requestHeaders,
296
+ body: JSON.stringify(request),
297
+ signal,
298
+ });
299
+ if (!response.ok) {
300
+ if (response.status === 404 && !_isRetry) {
301
+ (0, logger_1.getLogger)().info(`Session expired for server '${this.serverName}', re-initializing...`, {
302
+ event: "remote_session_expired",
303
+ server: this.serverName,
304
+ method,
305
+ });
306
+ this.initialized = false;
307
+ this.sessionId = null;
308
+ return this.makeRequest(method, params, true);
309
+ }
310
+ await this.handleHttpError(response, oauthHeaders);
303
311
  }
304
- await this.handleHttpError(response, oauthHeaders);
305
- }
306
- return this.parseOkResponse(response, request.id);
312
+ return this.parseOkResponse(response, request.id, signal);
313
+ });
307
314
  }
308
315
  /** Parse a confirmed-ok (2xx) HTTP response into the MCP result value. */
309
- async parseOkResponse(response, requestId) {
310
- // Check content type of response
316
+ async parseOkResponse(response, requestId, signal) {
311
317
  const contentType = response.headers.get("content-type");
312
318
  if (contentType?.includes("text/event-stream")) {
313
- // Server responded with SSE stream
314
319
  if (!response.body) {
315
320
  throw new Error("SSE response has no body");
316
321
  }
317
- return await this.parseSSEStream(response.body, requestId);
322
+ return this.parseSSEStream(response.body, requestId, signal);
318
323
  }
319
324
  else if (contentType?.includes("application/json")) {
320
- // Server responded with JSON (traditional HTTP transport)
321
325
  const result = await response.json();
322
326
  if (result.error) {
323
327
  throw new Error(`MCP Error: ${result.error.message}`);
@@ -325,8 +329,6 @@ class RemoteMCPServer {
325
329
  return result.result;
326
330
  }
327
331
  else if (response.status === 202) {
328
- // 202 Accepted - server will send response via separate GET SSE stream
329
- // This is less common; for now we don't support this pattern
330
332
  throw new Error("202 Accepted with separate SSE stream not yet supported. Server must respond inline with SSE or JSON.");
331
333
  }
332
334
  else {
@@ -350,7 +352,72 @@ class RemoteMCPServer {
350
352
  }
351
353
  }
352
354
  class LocalMCPServer {
353
- constructor(command, serverName, args = [], env = {}) {
355
+ invalidateProcessState(child) {
356
+ if (this.process === child) {
357
+ this.initialized = false;
358
+ this.initializing = null;
359
+ }
360
+ }
361
+ resetProcessState(child) {
362
+ if (this.process === child) {
363
+ this.invalidateProcessState(child);
364
+ this.process = null;
365
+ }
366
+ }
367
+ makeWriteError(error) {
368
+ const details = error instanceof Error ? error.message : String(error);
369
+ const errorCode = error?.code;
370
+ const diagnostic = errorCode === "EPIPE" || errorCode === "ERR_STREAM_DESTROYED"
371
+ ? `Server '${this.serverName}' closed its stdio pipe. Ensure the configured command starts a long-running MCP stdio server (not a one-shot command).`
372
+ : `Failed writing to server '${this.serverName}'. Check command/configuration and server logs.`;
373
+ return new types_1.MCPException(types_1.MCPErrorCode.SERVER_CRASHED, `Failed to write request to server '${this.serverName}'`, this.serverName, details, diagnostic);
374
+ }
375
+ async writeToProcess(child, payload) {
376
+ const stdin = child.stdin;
377
+ if (!stdin || stdin.destroyed || !stdin.writable) {
378
+ this.invalidateProcessState(child);
379
+ throw new types_1.MCPException(types_1.MCPErrorCode.SERVER_CRASHED, `Process stdin not available for server '${this.serverName}'`, this.serverName, this.stderrData.slice(-200) || undefined, `Server '${this.serverName}' exited before handling the request. Check command/configuration and server logs.`);
380
+ }
381
+ await new Promise((resolve, reject) => {
382
+ let settled = false;
383
+ const cleanup = () => {
384
+ stdin.off("error", onError);
385
+ };
386
+ const fail = (error) => {
387
+ if (settled)
388
+ return;
389
+ settled = true;
390
+ cleanup();
391
+ this.invalidateProcessState(child);
392
+ reject(this.makeWriteError(error));
393
+ };
394
+ const onError = (error) => {
395
+ fail(error);
396
+ };
397
+ stdin.once("error", onError);
398
+ try {
399
+ stdin.write(payload, (error) => {
400
+ if (error) {
401
+ fail(error);
402
+ return;
403
+ }
404
+ // Defer settle by one turn so late EPIPE emission is still captured
405
+ // by the temporary error listener for this write.
406
+ setImmediate(() => {
407
+ if (settled)
408
+ return;
409
+ settled = true;
410
+ cleanup();
411
+ resolve();
412
+ });
413
+ });
414
+ }
415
+ catch (error) {
416
+ fail(error);
417
+ }
418
+ });
419
+ }
420
+ constructor(command, serverName, args = [], env = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
354
421
  this.process = null;
355
422
  this.stderrData = "";
356
423
  this.initialized = false;
@@ -359,6 +426,7 @@ class LocalMCPServer {
359
426
  this.serverName = serverName;
360
427
  this.args = args;
361
428
  this.env = env;
429
+ this.timeoutMs = timeoutMs;
362
430
  }
363
431
  ensureProcess() {
364
432
  if (!this.process) {
@@ -367,17 +435,24 @@ class LocalMCPServer {
367
435
  stdio: ["pipe", "pipe", "pipe"],
368
436
  env: processEnv,
369
437
  });
438
+ const child = this.process;
370
439
  // Allow many concurrent in-flight requests without spurious warnings
371
440
  this.process.setMaxListeners(50);
441
+ child.stdin?.setMaxListeners(50);
442
+ child.stdout?.setMaxListeners(50);
372
443
  // Capture stderr for better error messages
373
444
  this.process.stderr?.on("data", (data) => {
374
445
  this.stderrData += data.toString();
375
446
  });
447
+ // Prevent unhandled EPIPE/stream errors from crashing the proxy process.
448
+ child.stdin?.on("error", () => {
449
+ this.invalidateProcessState(child);
450
+ });
376
451
  // Persistent close listener: clear the process reference so the next
377
452
  // makeRequest() call will spawn a fresh process instead of writing to
378
453
  // a dead stdin and hanging until the 10 s timeout fires.
379
- this.process.once("close", () => {
380
- this.process = null;
454
+ child.once("close", () => {
455
+ this.resetProcessState(child);
381
456
  });
382
457
  }
383
458
  }
@@ -406,10 +481,11 @@ class LocalMCPServer {
406
481
  if (!initResult?.protocolVersion) {
407
482
  throw new Error(`Server '${this.serverName}' returned invalid initialize response`);
408
483
  }
409
- if (!this.process?.stdin) {
484
+ const child = this.process;
485
+ if (!child) {
410
486
  throw new Error(`Process stdin not available for server '${this.serverName}'`);
411
487
  }
412
- this.process.stdin.write(JSON.stringify({
488
+ await this.writeToProcess(child, JSON.stringify({
413
489
  jsonrpc: "2.0",
414
490
  method: "notifications/initialized",
415
491
  params: {},
@@ -419,9 +495,14 @@ class LocalMCPServer {
419
495
  async sendRawRequest(method, params) {
420
496
  return new Promise((resolve, reject) => {
421
497
  this.ensureProcess();
498
+ const child = this.process;
499
+ if (!child) {
500
+ reject(new types_1.MCPException(types_1.MCPErrorCode.SERVER_CRASHED, `Failed to start server '${this.serverName}'`, this.serverName));
501
+ return;
502
+ }
422
503
  const request = {
423
504
  jsonrpc: "2.0",
424
- id: Date.now(),
505
+ id: (0, crypto_1.randomUUID)(),
425
506
  method,
426
507
  ...(params && { params }),
427
508
  };
@@ -429,11 +510,12 @@ class LocalMCPServer {
429
510
  let responseReceived = false;
430
511
  const timeoutId = setTimeout(() => {
431
512
  if (!responseReceived) {
432
- this.process?.stdout?.off("data", onData);
433
- const { code, diagnostic } = error_diagnostics_1.ErrorDiagnostics.analyzeTimeout(REQUEST_TIMEOUT_MS, this.stderrData, this.serverName);
434
- reject(new types_1.MCPException(code, `Server timeout (${REQUEST_TIMEOUT_MS / 1000}s)`, this.serverName, this.stderrData.slice(-200) || undefined, diagnostic));
513
+ child.stdout?.off("data", onData);
514
+ child.off("close", onClose);
515
+ const { code, diagnostic } = error_diagnostics_1.ErrorDiagnostics.analyzeTimeout(this.timeoutMs, this.stderrData, this.serverName);
516
+ reject(new types_1.MCPException(code, `Server timeout (${this.timeoutMs / 1000}s)`, this.serverName, this.stderrData.slice(-200) || undefined, diagnostic));
435
517
  }
436
- }, REQUEST_TIMEOUT_MS);
518
+ }, this.timeoutMs);
437
519
  const onData = (data) => {
438
520
  responseData += data.toString();
439
521
  const lines = responseData.split("\n");
@@ -445,8 +527,8 @@ class LocalMCPServer {
445
527
  if (response.jsonrpc === "2.0" && response.id === request.id) {
446
528
  responseReceived = true;
447
529
  clearTimeout(timeoutId);
448
- this.process?.stdout?.off("data", onData);
449
- this.process?.off("close", onClose);
530
+ child.stdout?.off("data", onData);
531
+ child.off("close", onClose);
450
532
  if (response.error) {
451
533
  reject(new Error(`MCP Error: ${response.error.message}`));
452
534
  }
@@ -466,15 +548,24 @@ class LocalMCPServer {
466
548
  const onClose = (code) => {
467
549
  if (!responseReceived) {
468
550
  clearTimeout(timeoutId);
469
- this.process?.stdout?.off("data", onData);
551
+ child.stdout?.off("data", onData);
552
+ child.off("close", onClose);
470
553
  const analysis = error_diagnostics_1.ErrorDiagnostics.analyzeStderr(this.stderrData, this.serverName);
471
554
  reject(new types_1.MCPException(analysis?.code || types_1.MCPErrorCode.SERVER_CRASHED, `Process exited with code ${code}`, this.serverName, this.stderrData.slice(-200) || undefined, analysis?.diagnostic ||
472
555
  `Server exited unexpectedly. Check stderr for details.`));
473
556
  }
474
557
  };
475
- this.process.stdout?.on("data", onData);
476
- this.process.once("close", onClose);
477
- this.process.stdin?.write(JSON.stringify(request) + "\n");
558
+ child.stdout?.on("data", onData);
559
+ child.once("close", onClose);
560
+ this.writeToProcess(child, JSON.stringify(request) + "\n").catch((error) => {
561
+ if (!responseReceived) {
562
+ responseReceived = true;
563
+ clearTimeout(timeoutId);
564
+ child.stdout?.off("data", onData);
565
+ child.off("close", onClose);
566
+ reject(error);
567
+ }
568
+ });
478
569
  });
479
570
  }
480
571
  async makeRequest(method, params) {
@@ -508,7 +599,7 @@ class ServerManager {
508
599
  const ref = version === 'unknown' ? 'main' : `v${version}`;
509
600
  return `https://gitlab.com/gitlab-org/ai/lazy-mcp/-/blob/${ref}/AGENTS.md`;
510
601
  }
511
- constructor(serverConfigs, cacheConfig = DEFAULT_CACHE_CONFIG, healthMonitorConfig, configFile) {
602
+ constructor(serverConfigs, cacheConfig = DEFAULT_CACHE_CONFIG, healthMonitorConfig, configFile, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
512
603
  this.servers = new Map();
513
604
  this.healthMonitorTimer = null;
514
605
  this.probing = false;
@@ -518,6 +609,7 @@ class ServerManager {
518
609
  /** Resolves when the first health probe round completes. null if monitor is disabled. */
519
610
  this.firstProbeComplete = null;
520
611
  this.cacheConfig = cacheConfig;
612
+ this.requestTimeoutMs = requestTimeoutMs;
521
613
  this.healthMonitorConfig = {
522
614
  ...DEFAULT_HEALTH_MONITOR_CONFIG,
523
615
  ...healthMonitorConfig,
@@ -525,8 +617,11 @@ class ServerManager {
525
617
  if (configFile)
526
618
  this.configFile = configFile;
527
619
  if (cacheConfig.strategy === "persistent") {
528
- console.error('Warning: cache strategy "persistent" is not yet implemented and will behave as "session" (in-memory). ' +
529
- 'Remove cachePath from your config or use strategy: "session".');
620
+ (0, logger_1.getLogger)().info('Warning: cache strategy "persistent" is not yet implemented and will behave as "session" (in-memory). ' +
621
+ 'Remove cachePath from your config or use strategy: "session".', {
622
+ event: "cache_strategy_warning",
623
+ strategy: cacheConfig.strategy,
624
+ });
530
625
  }
531
626
  for (const config of serverConfigs) {
532
627
  // Only add servers that are enabled (default to true if not specified)
@@ -812,15 +907,41 @@ class ServerManager {
812
907
  return Date.now() - health.lastChecked < cacheDuration;
813
908
  }
814
909
  async callTool(serverName, toolName, arguments_) {
910
+ const startedAt = Date.now();
911
+ const logger = (0, logger_1.getLogger)();
815
912
  const managed = this.servers.get(serverName);
816
913
  if (!managed) {
817
914
  throw new types_1.MCPException(types_1.MCPErrorCode.SERVER_NOT_FOUND, `Server '${serverName}' not found`, serverName, undefined, `Available servers: ${Array.from(this.servers.keys()).join(", ")}`);
818
915
  }
819
916
  const server = await this.getServer(serverName);
917
+ logger.info("Downstream tool execution started", {
918
+ event: "downstream_tool_execution",
919
+ phase: "start",
920
+ server: serverName,
921
+ command: toolName,
922
+ });
820
923
  try {
821
- return await server.callTool(toolName, arguments_);
924
+ const result = await server.callTool(toolName, arguments_);
925
+ logger.info("Downstream tool execution completed", {
926
+ event: "downstream_tool_execution",
927
+ phase: "finish",
928
+ status: "ok",
929
+ server: serverName,
930
+ command: toolName,
931
+ durationMs: Date.now() - startedAt,
932
+ });
933
+ return result;
822
934
  }
823
935
  catch (error) {
936
+ logger.error("Downstream tool execution failed", {
937
+ event: "downstream_tool_execution",
938
+ phase: "finish",
939
+ status: "error",
940
+ server: serverName,
941
+ command: toolName,
942
+ durationMs: Date.now() - startedAt,
943
+ error,
944
+ });
824
945
  // If the call failed, the server process may have died. Reset the
825
946
  // managed.server reference so the next call spawns a fresh connection
826
947
  // rather than writing to a dead process and hanging until timeout.
@@ -845,7 +966,7 @@ class ServerManager {
845
966
  const oauthManager = config.oauth
846
967
  ? new oauth_manager_1.OAuthManager(config.name, config.url, config.oauth)
847
968
  : undefined;
848
- return new RemoteMCPServer(config.url, config.name, config.headers || {}, oauthManager);
969
+ return new RemoteMCPServer(config.url, config.name, config.headers || {}, oauthManager, this.requestTimeoutMs);
849
970
  }
850
971
  else {
851
972
  if (!config.command) {
@@ -870,7 +991,7 @@ class ServerManager {
870
991
  args = config.args || [];
871
992
  }
872
993
  const env = config.env ? config_1.ConfigLoader.processEnv(config.env) : {};
873
- return new LocalMCPServer(command, config.name, args, env);
994
+ return new LocalMCPServer(command, config.name, args, env, this.requestTimeoutMs);
874
995
  }
875
996
  }
876
997
  /**
@@ -885,7 +1006,10 @@ class ServerManager {
885
1006
  this._startTimer();
886
1007
  // Fire an immediate probe on wake (non-blocking).
887
1008
  this.probeAllServers().catch((err) => {
888
- console.error("Health monitor error:", err);
1009
+ (0, logger_1.getLogger)().error("Health monitor error", {
1010
+ event: "health_monitor_error",
1011
+ error: err,
1012
+ });
889
1013
  });
890
1014
  }
891
1015
  }
@@ -933,7 +1057,10 @@ class ServerManager {
933
1057
  }
934
1058
  }
935
1059
  this.probeAllServers().catch((err) => {
936
- console.error("Health monitor error:", err);
1060
+ (0, logger_1.getLogger)().error("Health monitor error", {
1061
+ event: "health_monitor_error",
1062
+ error: err,
1063
+ });
937
1064
  });
938
1065
  }, this.healthMonitorConfig.interval);
939
1066
  // Don't let the timer keep the process alive