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.
- package/README.md +226 -1
- package/dist/cli.js +274 -35
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +117 -0
- package/dist/config.js.map +1 -1
- package/dist/http-server.d.ts +20 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +567 -0
- package/dist/http-server.js.map +1 -0
- package/dist/logger.d.ts +39 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +237 -0
- package/dist/logger.js.map +1 -0
- package/dist/server-manager.d.ts +4 -1
- package/dist/server-manager.d.ts.map +1 -1
- package/dist/server-manager.js +261 -134
- package/dist/server-manager.js.map +1 -1
- package/dist/server.d.ts +11 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +276 -188
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/server-manager.js
CHANGED
|
@@ -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
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
reader.cancel()
|
|
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 (!
|
|
77
|
-
|
|
78
|
-
|
|
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 (!
|
|
90
|
-
|
|
91
|
-
|
|
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 (!
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
198
|
+
signal,
|
|
175
199
|
});
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
|
232
|
+
signal,
|
|
223
233
|
});
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|