lazy-mcp 2.2.7 → 2.3.1

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");
@@ -8,6 +8,7 @@ const config_1 = require("./config");
8
8
  const error_diagnostics_1 = require("./error-diagnostics");
9
9
  const oauth_manager_1 = require("./oauth-manager");
10
10
  const version_1 = require("./version");
11
+ const logger_1 = require("./logger");
11
12
  const eventsource_parser_1 = require("eventsource-parser");
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";
@@ -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,27 @@ 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
+ const logger = (0, logger_1.getLogger)();
237
+ const preparedBody = logger.prepareBody(body);
238
+ let details;
239
+ if (typeof preparedBody.body === 'string') {
240
+ details = preparedBody.body.slice(0, 200);
241
+ }
242
+ else {
243
+ details = (0, logger_1.safeJsonStringify)(preparedBody.body).slice(0, 200);
244
+ }
245
+ 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, details, `Ensure the server implements the MCP Streamable HTTP transport correctly. It must accept POST notifications/initialized and return a 2xx status.`);
228
246
  }
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
- }
247
+ });
242
248
  this.initialized = true;
243
249
  }
244
250
  /**
@@ -254,9 +260,28 @@ class RemoteMCPServer {
254
260
  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
261
  }
256
262
  const errorText = await response.text();
257
- console.error(`Remote server error: HTTP ${response.status} ${response.statusText} - ${errorText}`);
263
+ const logger = (0, logger_1.getLogger)();
264
+ const preparedBody = logger.prepareBody(errorText);
265
+ logger.error("Remote server error", {
266
+ event: "remote_server_error",
267
+ server: this.serverName,
268
+ httpStatus: response.status,
269
+ httpStatusText: response.statusText,
270
+ responseBody: preparedBody.body,
271
+ responseBodyBytes: preparedBody.bodyBytes,
272
+ responseBodyLoggedBytes: preparedBody.bodyLoggedBytes,
273
+ responseBodyTruncated: preparedBody.bodyTruncated,
274
+ });
258
275
  const { code, diagnostic } = error_diagnostics_1.ErrorDiagnostics.analyzeHttpError(response.status, this.serverName);
259
- throw new types_1.MCPException(code, `HTTP ${response.status}: ${response.statusText}`, this.serverName, errorText.slice(0, 200), diagnostic);
276
+ let details;
277
+ if (typeof preparedBody.body === 'string') {
278
+ details = preparedBody.body.slice(0, 200);
279
+ }
280
+ else {
281
+ const serialized = (0, logger_1.safeJsonStringify)(preparedBody.body);
282
+ details = serialized.slice(0, 200);
283
+ }
284
+ throw new types_1.MCPException(code, `HTTP ${response.status}: ${response.statusText}`, this.serverName, details, diagnostic);
260
285
  }
261
286
  async makeRequest(method, params, _isRetry = false) {
262
287
  if (!this.serverUrl.startsWith("http")) {
@@ -265,7 +290,6 @@ class RemoteMCPServer {
265
290
  }
266
291
  throw new Error("Only HTTP and WebSocket URLs are supported");
267
292
  }
268
- // Ensure MCP handshake has been performed (idempotent after first call)
269
293
  await this.ensureInitialized();
270
294
  const request = {
271
295
  jsonrpc: "2.0",
@@ -273,51 +297,48 @@ class RemoteMCPServer {
273
297
  method,
274
298
  ...(params && { params }),
275
299
  };
276
- // Inject OAuth headers if we have a valid stored token
277
300
  const oauthHeaders = this.oauthManager.getStoredAuthHeaders() ?? {};
278
301
  const requestHeaders = {
279
302
  ...this.headers,
280
303
  ...oauthHeaders,
281
304
  Accept: "application/json, text/event-stream",
282
305
  };
283
- // Include session ID in all requests after initialization
284
306
  if (this.sessionId) {
285
307
  requestHeaders[MCP_SESSION_HEADER] = this.sessionId;
286
308
  }
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);
309
+ return this.withRequestTimeout(`request '${method}'`, async (signal) => {
310
+ const response = await fetch(this.serverUrl, {
311
+ method: "POST",
312
+ headers: requestHeaders,
313
+ body: JSON.stringify(request),
314
+ signal,
315
+ });
316
+ if (!response.ok) {
317
+ if (response.status === 404 && !_isRetry) {
318
+ (0, logger_1.getLogger)().info(`Session expired for server '${this.serverName}', re-initializing...`, {
319
+ event: "remote_session_expired",
320
+ server: this.serverName,
321
+ method,
322
+ });
323
+ this.initialized = false;
324
+ this.sessionId = null;
325
+ return this.makeRequest(method, params, true);
326
+ }
327
+ await this.handleHttpError(response, oauthHeaders);
303
328
  }
304
- await this.handleHttpError(response, oauthHeaders);
305
- }
306
- return this.parseOkResponse(response, request.id);
329
+ return this.parseOkResponse(response, request.id, signal);
330
+ });
307
331
  }
308
332
  /** Parse a confirmed-ok (2xx) HTTP response into the MCP result value. */
309
- async parseOkResponse(response, requestId) {
310
- // Check content type of response
333
+ async parseOkResponse(response, requestId, signal) {
311
334
  const contentType = response.headers.get("content-type");
312
335
  if (contentType?.includes("text/event-stream")) {
313
- // Server responded with SSE stream
314
336
  if (!response.body) {
315
337
  throw new Error("SSE response has no body");
316
338
  }
317
- return await this.parseSSEStream(response.body, requestId);
339
+ return this.parseSSEStream(response.body, requestId, signal);
318
340
  }
319
341
  else if (contentType?.includes("application/json")) {
320
- // Server responded with JSON (traditional HTTP transport)
321
342
  const result = await response.json();
322
343
  if (result.error) {
323
344
  throw new Error(`MCP Error: ${result.error.message}`);
@@ -325,8 +346,6 @@ class RemoteMCPServer {
325
346
  return result.result;
326
347
  }
327
348
  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
349
  throw new Error("202 Accepted with separate SSE stream not yet supported. Server must respond inline with SSE or JSON.");
331
350
  }
332
351
  else {
@@ -350,7 +369,72 @@ class RemoteMCPServer {
350
369
  }
351
370
  }
352
371
  class LocalMCPServer {
353
- constructor(command, serverName, args = [], env = {}) {
372
+ invalidateProcessState(child) {
373
+ if (this.process === child) {
374
+ this.initialized = false;
375
+ this.initializing = null;
376
+ }
377
+ }
378
+ resetProcessState(child) {
379
+ if (this.process === child) {
380
+ this.invalidateProcessState(child);
381
+ this.process = null;
382
+ }
383
+ }
384
+ makeWriteError(error) {
385
+ const details = error instanceof Error ? error.message : String(error);
386
+ const errorCode = error?.code;
387
+ const diagnostic = errorCode === "EPIPE" || errorCode === "ERR_STREAM_DESTROYED"
388
+ ? `Server '${this.serverName}' closed its stdio pipe. Ensure the configured command starts a long-running MCP stdio server (not a one-shot command).`
389
+ : `Failed writing to server '${this.serverName}'. Check command/configuration and server logs.`;
390
+ return new types_1.MCPException(types_1.MCPErrorCode.SERVER_CRASHED, `Failed to write request to server '${this.serverName}'`, this.serverName, details, diagnostic);
391
+ }
392
+ async writeToProcess(child, payload) {
393
+ const stdin = child.stdin;
394
+ if (!stdin || stdin.destroyed || !stdin.writable) {
395
+ this.invalidateProcessState(child);
396
+ 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.`);
397
+ }
398
+ await new Promise((resolve, reject) => {
399
+ let settled = false;
400
+ const cleanup = () => {
401
+ stdin.off("error", onError);
402
+ };
403
+ const fail = (error) => {
404
+ if (settled)
405
+ return;
406
+ settled = true;
407
+ cleanup();
408
+ this.invalidateProcessState(child);
409
+ reject(this.makeWriteError(error));
410
+ };
411
+ const onError = (error) => {
412
+ fail(error);
413
+ };
414
+ stdin.once("error", onError);
415
+ try {
416
+ stdin.write(payload, (error) => {
417
+ if (error) {
418
+ fail(error);
419
+ return;
420
+ }
421
+ // Defer settle by one turn so late EPIPE emission is still captured
422
+ // by the temporary error listener for this write.
423
+ setImmediate(() => {
424
+ if (settled)
425
+ return;
426
+ settled = true;
427
+ cleanup();
428
+ resolve();
429
+ });
430
+ });
431
+ }
432
+ catch (error) {
433
+ fail(error);
434
+ }
435
+ });
436
+ }
437
+ constructor(command, serverName, args = [], env = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
354
438
  this.process = null;
355
439
  this.stderrData = "";
356
440
  this.initialized = false;
@@ -359,6 +443,7 @@ class LocalMCPServer {
359
443
  this.serverName = serverName;
360
444
  this.args = args;
361
445
  this.env = env;
446
+ this.timeoutMs = timeoutMs;
362
447
  }
363
448
  ensureProcess() {
364
449
  if (!this.process) {
@@ -367,17 +452,24 @@ class LocalMCPServer {
367
452
  stdio: ["pipe", "pipe", "pipe"],
368
453
  env: processEnv,
369
454
  });
455
+ const child = this.process;
370
456
  // Allow many concurrent in-flight requests without spurious warnings
371
457
  this.process.setMaxListeners(50);
458
+ child.stdin?.setMaxListeners(50);
459
+ child.stdout?.setMaxListeners(50);
372
460
  // Capture stderr for better error messages
373
461
  this.process.stderr?.on("data", (data) => {
374
462
  this.stderrData += data.toString();
375
463
  });
464
+ // Prevent unhandled EPIPE/stream errors from crashing the proxy process.
465
+ child.stdin?.on("error", () => {
466
+ this.invalidateProcessState(child);
467
+ });
376
468
  // Persistent close listener: clear the process reference so the next
377
469
  // makeRequest() call will spawn a fresh process instead of writing to
378
470
  // a dead stdin and hanging until the 10 s timeout fires.
379
- this.process.once("close", () => {
380
- this.process = null;
471
+ child.once("close", () => {
472
+ this.resetProcessState(child);
381
473
  });
382
474
  }
383
475
  }
@@ -406,10 +498,11 @@ class LocalMCPServer {
406
498
  if (!initResult?.protocolVersion) {
407
499
  throw new Error(`Server '${this.serverName}' returned invalid initialize response`);
408
500
  }
409
- if (!this.process?.stdin) {
501
+ const child = this.process;
502
+ if (!child) {
410
503
  throw new Error(`Process stdin not available for server '${this.serverName}'`);
411
504
  }
412
- this.process.stdin.write(JSON.stringify({
505
+ await this.writeToProcess(child, JSON.stringify({
413
506
  jsonrpc: "2.0",
414
507
  method: "notifications/initialized",
415
508
  params: {},
@@ -419,9 +512,14 @@ class LocalMCPServer {
419
512
  async sendRawRequest(method, params) {
420
513
  return new Promise((resolve, reject) => {
421
514
  this.ensureProcess();
515
+ const child = this.process;
516
+ if (!child) {
517
+ reject(new types_1.MCPException(types_1.MCPErrorCode.SERVER_CRASHED, `Failed to start server '${this.serverName}'`, this.serverName));
518
+ return;
519
+ }
422
520
  const request = {
423
521
  jsonrpc: "2.0",
424
- id: Date.now(),
522
+ id: (0, crypto_1.randomUUID)(),
425
523
  method,
426
524
  ...(params && { params }),
427
525
  };
@@ -429,11 +527,12 @@ class LocalMCPServer {
429
527
  let responseReceived = false;
430
528
  const timeoutId = setTimeout(() => {
431
529
  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));
530
+ child.stdout?.off("data", onData);
531
+ child.off("close", onClose);
532
+ const { code, diagnostic } = error_diagnostics_1.ErrorDiagnostics.analyzeTimeout(this.timeoutMs, this.stderrData, this.serverName);
533
+ reject(new types_1.MCPException(code, `Server timeout (${this.timeoutMs / 1000}s)`, this.serverName, this.stderrData.slice(-200) || undefined, diagnostic));
435
534
  }
436
- }, REQUEST_TIMEOUT_MS);
535
+ }, this.timeoutMs);
437
536
  const onData = (data) => {
438
537
  responseData += data.toString();
439
538
  const lines = responseData.split("\n");
@@ -445,8 +544,8 @@ class LocalMCPServer {
445
544
  if (response.jsonrpc === "2.0" && response.id === request.id) {
446
545
  responseReceived = true;
447
546
  clearTimeout(timeoutId);
448
- this.process?.stdout?.off("data", onData);
449
- this.process?.off("close", onClose);
547
+ child.stdout?.off("data", onData);
548
+ child.off("close", onClose);
450
549
  if (response.error) {
451
550
  reject(new Error(`MCP Error: ${response.error.message}`));
452
551
  }
@@ -466,15 +565,24 @@ class LocalMCPServer {
466
565
  const onClose = (code) => {
467
566
  if (!responseReceived) {
468
567
  clearTimeout(timeoutId);
469
- this.process?.stdout?.off("data", onData);
568
+ child.stdout?.off("data", onData);
569
+ child.off("close", onClose);
470
570
  const analysis = error_diagnostics_1.ErrorDiagnostics.analyzeStderr(this.stderrData, this.serverName);
471
571
  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
572
  `Server exited unexpectedly. Check stderr for details.`));
473
573
  }
474
574
  };
475
- this.process.stdout?.on("data", onData);
476
- this.process.once("close", onClose);
477
- this.process.stdin?.write(JSON.stringify(request) + "\n");
575
+ child.stdout?.on("data", onData);
576
+ child.once("close", onClose);
577
+ this.writeToProcess(child, JSON.stringify(request) + "\n").catch((error) => {
578
+ if (!responseReceived) {
579
+ responseReceived = true;
580
+ clearTimeout(timeoutId);
581
+ child.stdout?.off("data", onData);
582
+ child.off("close", onClose);
583
+ reject(error);
584
+ }
585
+ });
478
586
  });
479
587
  }
480
588
  async makeRequest(method, params) {
@@ -508,7 +616,7 @@ class ServerManager {
508
616
  const ref = version === 'unknown' ? 'main' : `v${version}`;
509
617
  return `https://gitlab.com/gitlab-org/ai/lazy-mcp/-/blob/${ref}/AGENTS.md`;
510
618
  }
511
- constructor(serverConfigs, cacheConfig = DEFAULT_CACHE_CONFIG, healthMonitorConfig, configFile) {
619
+ constructor(serverConfigs, cacheConfig = DEFAULT_CACHE_CONFIG, healthMonitorConfig, configFile, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
512
620
  this.servers = new Map();
513
621
  this.healthMonitorTimer = null;
514
622
  this.probing = false;
@@ -518,6 +626,7 @@ class ServerManager {
518
626
  /** Resolves when the first health probe round completes. null if monitor is disabled. */
519
627
  this.firstProbeComplete = null;
520
628
  this.cacheConfig = cacheConfig;
629
+ this.requestTimeoutMs = requestTimeoutMs;
521
630
  this.healthMonitorConfig = {
522
631
  ...DEFAULT_HEALTH_MONITOR_CONFIG,
523
632
  ...healthMonitorConfig,
@@ -525,8 +634,11 @@ class ServerManager {
525
634
  if (configFile)
526
635
  this.configFile = configFile;
527
636
  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".');
637
+ (0, logger_1.getLogger)().info('Warning: cache strategy "persistent" is not yet implemented and will behave as "session" (in-memory). ' +
638
+ 'Remove cachePath from your config or use strategy: "session".', {
639
+ event: "cache_strategy_warning",
640
+ strategy: cacheConfig.strategy,
641
+ });
530
642
  }
531
643
  for (const config of serverConfigs) {
532
644
  // Only add servers that are enabled (default to true if not specified)
@@ -812,15 +924,41 @@ class ServerManager {
812
924
  return Date.now() - health.lastChecked < cacheDuration;
813
925
  }
814
926
  async callTool(serverName, toolName, arguments_) {
927
+ const startedAt = Date.now();
928
+ const logger = (0, logger_1.getLogger)();
815
929
  const managed = this.servers.get(serverName);
816
930
  if (!managed) {
817
931
  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
932
  }
819
933
  const server = await this.getServer(serverName);
934
+ logger.info("Downstream tool execution started", {
935
+ event: "downstream_tool_execution",
936
+ phase: "start",
937
+ server: serverName,
938
+ command: toolName,
939
+ });
820
940
  try {
821
- return await server.callTool(toolName, arguments_);
941
+ const result = await server.callTool(toolName, arguments_);
942
+ logger.info("Downstream tool execution completed", {
943
+ event: "downstream_tool_execution",
944
+ phase: "finish",
945
+ status: "ok",
946
+ server: serverName,
947
+ command: toolName,
948
+ durationMs: Date.now() - startedAt,
949
+ });
950
+ return result;
822
951
  }
823
952
  catch (error) {
953
+ logger.error("Downstream tool execution failed", {
954
+ event: "downstream_tool_execution",
955
+ phase: "finish",
956
+ status: "error",
957
+ server: serverName,
958
+ command: toolName,
959
+ durationMs: Date.now() - startedAt,
960
+ error,
961
+ });
824
962
  // If the call failed, the server process may have died. Reset the
825
963
  // managed.server reference so the next call spawns a fresh connection
826
964
  // rather than writing to a dead process and hanging until timeout.
@@ -845,7 +983,7 @@ class ServerManager {
845
983
  const oauthManager = config.oauth
846
984
  ? new oauth_manager_1.OAuthManager(config.name, config.url, config.oauth)
847
985
  : undefined;
848
- return new RemoteMCPServer(config.url, config.name, config.headers || {}, oauthManager);
986
+ return new RemoteMCPServer(config.url, config.name, config.headers || {}, oauthManager, this.requestTimeoutMs);
849
987
  }
850
988
  else {
851
989
  if (!config.command) {
@@ -870,7 +1008,7 @@ class ServerManager {
870
1008
  args = config.args || [];
871
1009
  }
872
1010
  const env = config.env ? config_1.ConfigLoader.processEnv(config.env) : {};
873
- return new LocalMCPServer(command, config.name, args, env);
1011
+ return new LocalMCPServer(command, config.name, args, env, this.requestTimeoutMs);
874
1012
  }
875
1013
  }
876
1014
  /**
@@ -885,7 +1023,10 @@ class ServerManager {
885
1023
  this._startTimer();
886
1024
  // Fire an immediate probe on wake (non-blocking).
887
1025
  this.probeAllServers().catch((err) => {
888
- console.error("Health monitor error:", err);
1026
+ (0, logger_1.getLogger)().error("Health monitor error", {
1027
+ event: "health_monitor_error",
1028
+ error: err,
1029
+ });
889
1030
  });
890
1031
  }
891
1032
  }
@@ -933,7 +1074,10 @@ class ServerManager {
933
1074
  }
934
1075
  }
935
1076
  this.probeAllServers().catch((err) => {
936
- console.error("Health monitor error:", err);
1077
+ (0, logger_1.getLogger)().error("Health monitor error", {
1078
+ event: "health_monitor_error",
1079
+ error: err,
1080
+ });
937
1081
  });
938
1082
  }, this.healthMonitorConfig.interval);
939
1083
  // Don't let the timer keep the process alive