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.
- package/README.md +252 -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 +578 -0
- package/dist/http-server.js.map +1 -0
- package/dist/logger.d.ts +40 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +238 -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 +279 -135
- 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");
|
|
@@ -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
|
|
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,27 @@ 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|