sap-ai-dev-toolkit 0.5.0 → 0.5.6
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/.github/agents/abap-developer.agent.md +3 -3
- package/.github/agents/abap-runtime-debugger.agent.md +3 -3
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-debugging/SKILL.md +3 -3
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +2 -2
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +17 -11
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +17 -18
- package/src/bas-destination-relay.mjs +58 -22
- package/src/bas-discovery.mjs +24 -8
- package/src/binary.mjs +101 -17
- package/src/cf-destination.mjs +55 -1
- package/src/credentials-store.mjs +0 -10
- package/src/engineering-tools.mjs +32 -10
- package/src/hana-tools.mjs +9 -2
- package/src/launcher.mjs +22 -19
- package/src/mcp-config.mjs +41 -7
- package/src/mcp-proxy.mjs +392 -104
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +33 -3
- package/tools.md +4 -2
- package/test/bas-destination-relay.test.mjs +0 -374
- package/test/cf-connectivity.test.mjs +0 -287
- package/test/cf-destination.test.mjs +0 -307
- package/test/cf-runtime.test.mjs +0 -527
- package/test/copilot-assets.test.mjs +0 -63
- package/test/copilot-content.test.mjs +0 -249
- package/test/credentials-store.test.mjs +0 -187
- package/test/discovery.test.mjs +0 -257
- package/test/fixtures/fake-vsp.mjs +0 -263
- package/test/hana-config.test.mjs +0 -168
- package/test/hana-inspector-stdio.test.mjs +0 -44
- package/test/hana-tools.test.mjs +0 -206
- package/test/launcher.test.mjs +0 -399
- package/test/live-s4h.test.mjs +0 -202
- package/test/mcp-config-cf.test.mjs +0 -407
- package/test/mcp-proxy.test.mjs +0 -409
- package/test/pty.mjs +0 -8
- package/test/setup-cf.test.mjs +0 -489
- package/test/setup.test.mjs +0 -417
- package/test/terminal-ui.test.mjs +0 -47
package/src/mcp-proxy.mjs
CHANGED
|
@@ -5,9 +5,13 @@ import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
|
|
|
5
5
|
import { createEngineeringTools } from './engineering-tools.mjs';
|
|
6
6
|
import { brandedEnvValue } from './branding.mjs';
|
|
7
7
|
import { createBasDestinationRelay } from './bas-destination-relay.mjs';
|
|
8
|
+
import { diagnosticText, redactText } from './redact.mjs';
|
|
8
9
|
|
|
9
10
|
const JSONRPC = '2.0';
|
|
10
11
|
const FORWARDED_METHODS = new Set(['ping', 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'completion/complete', 'logging/setLevel']);
|
|
12
|
+
// MCP log levels in spec order; a notification is emitted when its level is
|
|
13
|
+
// at or above the client's logging/setLevel choice (default info).
|
|
14
|
+
const MCP_LOG_LEVELS = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];
|
|
11
15
|
const APPLICATION_LOG_PARAMETERS = [
|
|
12
16
|
'program',
|
|
13
17
|
'user',
|
|
@@ -34,6 +38,51 @@ const APPLICATION_LOG_SCHEMA = {
|
|
|
34
38
|
};
|
|
35
39
|
const APPLICATION_LOG_DESCRIPTION = 'Read SAP application log (SLG1) entries. Results are newest first and limited to 100 by default; set messages=true to include log message details.';
|
|
36
40
|
|
|
41
|
+
// Read-only mode (SAP_AI_DEV_TOOLKIT_READ_ONLY=true) removes every tool that
|
|
42
|
+
// changes SAP state from the public surface and starts VSP with
|
|
43
|
+
// --transport-read-only, so an exploration destination cannot be written to
|
|
44
|
+
// through this proxy even if a chat prompt asks for it.
|
|
45
|
+
const READ_ONLY_HIDDEN_VSP_TOOLS = new Set([
|
|
46
|
+
'Activate',
|
|
47
|
+
'ActivateMultiple',
|
|
48
|
+
'CreateTransport',
|
|
49
|
+
'EditSource',
|
|
50
|
+
'SetBreakpoint',
|
|
51
|
+
'WriteSource'
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// Self-healing restarts retry the interrupted tools/call once. Retrying a
|
|
55
|
+
// state-changing tool after a crash can apply the same write twice (the child
|
|
56
|
+
// may have completed the side effect before dying), so these are never retried;
|
|
57
|
+
// the original error is surfaced instead.
|
|
58
|
+
const NON_RETRIABLE_VSP_TOOLS = new Set([
|
|
59
|
+
'Activate',
|
|
60
|
+
'ActivateMultiple',
|
|
61
|
+
'CreateTransport',
|
|
62
|
+
'EditSource',
|
|
63
|
+
'WriteSource'
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const MAX_CHILD_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
67
|
+
const MAX_PENDING_NOTIFICATIONS = 200;
|
|
68
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
69
|
+
const MAX_RESTARTS_PER_WINDOW = 5;
|
|
70
|
+
const RESTART_WINDOW_MS = 5 * 60 * 1000;
|
|
71
|
+
|
|
72
|
+
function requestTimeoutMs(env) {
|
|
73
|
+
const raw = brandedEnvValue(env, 'REQUEST_TIMEOUT_MS');
|
|
74
|
+
if (raw === undefined || raw === '') return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
75
|
+
const parsed = Number(raw);
|
|
76
|
+
// 0 or a negative value disables the timeout; invalid values fall back.
|
|
77
|
+
if (!Number.isFinite(parsed)) return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
78
|
+
return parsed > 0 ? Math.floor(parsed) : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readOnlyMode(env = process.env) {
|
|
82
|
+
const raw = brandedEnvValue(env, 'READ_ONLY');
|
|
83
|
+
return raw !== undefined && ['1', 'true', 'yes'].includes(String(raw).trim().toLowerCase());
|
|
84
|
+
}
|
|
85
|
+
|
|
37
86
|
// Keep the default public VSP surface small enough for developer-lifecycle use.
|
|
38
87
|
// Hidden upstream tools can still be used by local workflow tools when needed.
|
|
39
88
|
export const PUBLIC_VSP_TOOLS = new Set([
|
|
@@ -90,8 +139,27 @@ export const PUBLIC_VSP_TOOLS = new Set([
|
|
|
90
139
|
'WriteSource'
|
|
91
140
|
]);
|
|
92
141
|
|
|
93
|
-
function exposeVspTool(tool) {
|
|
94
|
-
|
|
142
|
+
function exposeVspTool(tool, readOnly = false) {
|
|
143
|
+
if (!PUBLIC_VSP_TOOLS.has(tool?.name)) return false;
|
|
144
|
+
// Read-only destinations never expose the write/activate/transport-create
|
|
145
|
+
// surface, even when VSP registers it.
|
|
146
|
+
return !(readOnly && READ_ONLY_HIDDEN_VSP_TOOLS.has(tool.name));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Public tool names are lowercase snake_case. BAS/VS Code chat tool references
|
|
150
|
+
// are lowercase-only, so mixed-case names never bind in the tools picker even
|
|
151
|
+
// when the server itself starts and lists tools.
|
|
152
|
+
export function snakeCaseName(value) {
|
|
153
|
+
return String(value)
|
|
154
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
155
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
156
|
+
.toLowerCase();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Every generated tool is prefixed with the destination it targets:
|
|
160
|
+
// <destination-slug>_<tool>, for example demo-abap_get_table_contents.
|
|
161
|
+
function publicToolSegment(name) {
|
|
162
|
+
return snakeCaseName(name) || String(name).toLowerCase();
|
|
95
163
|
}
|
|
96
164
|
|
|
97
165
|
function applicationLogArguments(arguments_ = {}) {
|
|
@@ -105,8 +173,6 @@ function applicationLogArguments(arguments_ = {}) {
|
|
|
105
173
|
|
|
106
174
|
function rpcResult(id, result) { return { jsonrpc: JSONRPC, id, result }; }
|
|
107
175
|
function rpcError(id, code, message, data) { return { jsonrpc: JSONRPC, id, error: { code, message, ...(data === undefined ? {} : { data }) } }; }
|
|
108
|
-
function redactText(text) { return String(text).replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]'); }
|
|
109
|
-
function diagnosticText(text) { return redactText(text || 'unknown error').replace(/\s+/g, ' ').slice(0, 500); }
|
|
110
176
|
|
|
111
177
|
const closedRoutes = new WeakMap();
|
|
112
178
|
|
|
@@ -149,7 +215,8 @@ export function childArguments(destination, env = process.env) {
|
|
|
149
215
|
const mode = brandedEnvValue(env, 'MODE') || 'expert';
|
|
150
216
|
const args = ['--url', destination.url, '--client', destination.client || '001', '--mode', mode];
|
|
151
217
|
if (destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation') args.push('--proxy-auth');
|
|
152
|
-
args.push('--
|
|
218
|
+
if (readOnlyMode(env)) args.push('--transport-read-only');
|
|
219
|
+
else args.push('--enable-transports');
|
|
153
220
|
return args;
|
|
154
221
|
}
|
|
155
222
|
|
|
@@ -162,6 +229,7 @@ class Child {
|
|
|
162
229
|
this.buffer = '';
|
|
163
230
|
this.exited = false;
|
|
164
231
|
this.closing = false;
|
|
232
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
|
|
165
233
|
this.process = (options.spawn || nodeSpawn)(binary, options.args || childArguments(destination, options.env), {
|
|
166
234
|
env: childEnvironment(destination, options.env),
|
|
167
235
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
@@ -172,6 +240,13 @@ class Child {
|
|
|
172
240
|
this.process.stderr.on('data', chunk => {
|
|
173
241
|
if (options.log) options.log(`[${destination.name}] ${redactText(chunk).trimEnd()}`);
|
|
174
242
|
});
|
|
243
|
+
// A write racing child death surfaces as an EPIPE 'error' event on stdin.
|
|
244
|
+
// Without a listener that is an uncaught exception that kills the whole
|
|
245
|
+
// MCP server — exactly the crash window self-healing is meant to cover.
|
|
246
|
+
this.process.stdin.on('error', error => {
|
|
247
|
+
if (!this.closing && this.options.log) this.options.log(`[${destination.name}] VSP child stdin error: ${diagnosticText(error.message)}`);
|
|
248
|
+
this.fail(new Error(`child stdin failed: ${error.message}`));
|
|
249
|
+
});
|
|
175
250
|
this.process.on('error', error => {
|
|
176
251
|
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
|
|
177
252
|
this.fail(error);
|
|
@@ -188,18 +263,30 @@ class Child {
|
|
|
188
263
|
|
|
189
264
|
onData(chunk) {
|
|
190
265
|
this.buffer += chunk;
|
|
266
|
+
if (this.buffer.length > MAX_CHILD_BUFFER_BYTES) {
|
|
267
|
+
// A child streaming non-JSON garbage would grow this buffer unbounded.
|
|
268
|
+
this.fail(new Error('child stdout exceeded the buffered line limit'));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
191
271
|
let newline;
|
|
192
272
|
while ((newline = this.buffer.indexOf('\n')) >= 0) {
|
|
193
273
|
const line = this.buffer.slice(0, newline).trim();
|
|
194
274
|
this.buffer = this.buffer.slice(newline + 1);
|
|
195
275
|
if (!line) continue;
|
|
196
|
-
|
|
276
|
+
// One garbled line (interleaved stderr, partial write) must not reject
|
|
277
|
+
// every in-flight request: skip it and let the per-request timeout or
|
|
278
|
+
// child exit handle a genuinely desynchronized stream.
|
|
279
|
+
try { this.onMessage(JSON.parse(line)); }
|
|
280
|
+
catch (error) { this.options.log?.(`[${this.destination.name}] dropped a non-JSON child line: ${diagnosticText(error.message)}`); }
|
|
197
281
|
}
|
|
198
282
|
}
|
|
199
283
|
|
|
200
284
|
onMessage(message) {
|
|
201
285
|
if (message.id === undefined || message.id === null) {
|
|
202
286
|
if (message.method?.startsWith('notifications/')) {
|
|
287
|
+
// An upstream tool-surface change invalidates this child's cached
|
|
288
|
+
// listing; the proxy also drops its merged cache when forwarding.
|
|
289
|
+
if (message.method === 'notifications/tools/list_changed') this.toolsPromise = undefined;
|
|
203
290
|
try { this.options.onNotification?.(message); }
|
|
204
291
|
catch (error) { this.options.log?.(`[${this.destination.name}] child notification forwarding failed: ${diagnosticText(error.message)}`); }
|
|
205
292
|
}
|
|
@@ -215,6 +302,8 @@ class Child {
|
|
|
215
302
|
fail(error) {
|
|
216
303
|
if (this.exited) return;
|
|
217
304
|
this.exited = true;
|
|
305
|
+
// A dead child must not keep serving its last successful tool listing.
|
|
306
|
+
this.toolsPromise = undefined;
|
|
218
307
|
for (const pending of this.pending.values()) pending.reject(error);
|
|
219
308
|
this.pending.clear();
|
|
220
309
|
}
|
|
@@ -223,9 +312,22 @@ class Child {
|
|
|
223
312
|
if (this.exited || !this.process.stdin.writable) return Promise.reject(new Error(`destination ${this.destination.name} child is not running`));
|
|
224
313
|
const id = this.nextId++;
|
|
225
314
|
return new Promise((resolve, reject) => {
|
|
226
|
-
|
|
315
|
+
// A hung child (network stall, backend deadlock) must not hang the call
|
|
316
|
+
// forever. The timeout only fails this request; the child is left alone
|
|
317
|
+
// because killing it would also kill unrelated in-flight requests.
|
|
318
|
+
let timeout;
|
|
319
|
+
const cancel = () => clearTimeout(timeout);
|
|
320
|
+
this.pending.set(id, {
|
|
321
|
+
resolve: value => { cancel(); resolve(value); },
|
|
322
|
+
reject: error => { cancel(); reject(error); }
|
|
323
|
+
});
|
|
324
|
+
if (this.requestTimeoutMs > 0) {
|
|
325
|
+
timeout = setTimeout(() => {
|
|
326
|
+
if (this.pending.delete(id)) reject(new Error(`destination ${this.destination.name} did not answer ${method} within ${this.requestTimeoutMs}ms`));
|
|
327
|
+
}, this.requestTimeoutMs);
|
|
328
|
+
}
|
|
227
329
|
try { this.process.stdin.write(`${JSON.stringify({ jsonrpc: JSONRPC, id, method, ...(params === undefined ? {} : { params })})}\n`); }
|
|
228
|
-
catch (error) { this.pending.delete(id); reject(error); }
|
|
330
|
+
catch (error) { cancel(); this.pending.delete(id); reject(error); }
|
|
229
331
|
});
|
|
230
332
|
}
|
|
231
333
|
|
|
@@ -251,6 +353,19 @@ class Child {
|
|
|
251
353
|
return tools;
|
|
252
354
|
}
|
|
253
355
|
|
|
356
|
+
// The VSP tool surface is fixed for the process lifetime (--mode is a
|
|
357
|
+
// launch argument), so listings are memoized per child. The memo is
|
|
358
|
+
// cleared on failure, on child exit, and on upstream list_changed.
|
|
359
|
+
listToolsCached() {
|
|
360
|
+
if (!this.toolsPromise) {
|
|
361
|
+
this.toolsPromise = this.listTools().catch(error => {
|
|
362
|
+
this.toolsPromise = undefined;
|
|
363
|
+
throw error;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return this.toolsPromise;
|
|
367
|
+
}
|
|
368
|
+
|
|
254
369
|
async close() {
|
|
255
370
|
if (this.exited) return;
|
|
256
371
|
this.closing = true;
|
|
@@ -270,7 +385,7 @@ class Child {
|
|
|
270
385
|
}
|
|
271
386
|
|
|
272
387
|
export class MCPProxy {
|
|
273
|
-
constructor({ binary, destinations, env = process.env, spawn = nodeSpawn, childArgs, log = message => console.error(message), output = line => process.stdout.write(`${line}\n`) }) {
|
|
388
|
+
constructor({ binary, destinations, env = process.env, spawn = nodeSpawn, childArgs, log = message => console.error(message), output = line => process.stdout.write(`${line}\n`), version = '0.0.0' }) {
|
|
274
389
|
this.binary = binary;
|
|
275
390
|
this.destinations = destinations;
|
|
276
391
|
this.env = env;
|
|
@@ -278,6 +393,9 @@ export class MCPProxy {
|
|
|
278
393
|
this.childArgs = childArgs;
|
|
279
394
|
this.log = log;
|
|
280
395
|
this.output = output;
|
|
396
|
+
this.version = version;
|
|
397
|
+
this.readOnly = readOnlyMode(env);
|
|
398
|
+
this.requestTimeoutMs = requestTimeoutMs(env);
|
|
281
399
|
this.children = [];
|
|
282
400
|
this.started = false;
|
|
283
401
|
this.namespace = new Map();
|
|
@@ -285,18 +403,60 @@ export class MCPProxy {
|
|
|
285
403
|
this.clientInitialized = false;
|
|
286
404
|
this.pendingNotifications = [];
|
|
287
405
|
this.shuttingDown = false;
|
|
288
|
-
this.
|
|
406
|
+
this.toolsCache = null;
|
|
407
|
+
this.mergedToolsPromise = undefined;
|
|
408
|
+
this.mcpLogLevel = 'info';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
mcpLogEnabled(level) {
|
|
412
|
+
return MCP_LOG_LEVELS.indexOf(level) >= MCP_LOG_LEVELS.indexOf(this.mcpLogLevel);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Every observable proxy event lands on stderr and, as a standard
|
|
416
|
+
// notifications/message log event, in the host's MCP server output channel
|
|
417
|
+
// (VS Code/BAS and Claude Code both render these). Events raised before the
|
|
418
|
+
// client initializes are buffered so the channel still shows the full
|
|
419
|
+
// startup picture once it opens. Everything passes through redactText so no
|
|
420
|
+
// lane can leak a credential that reached a diagnostic string.
|
|
421
|
+
eventSink(message, level = 'info', logger = 'sap-ai-dev-toolkit') {
|
|
422
|
+
const text = redactText(message);
|
|
423
|
+
this.log(text);
|
|
424
|
+
if (!this.mcpLogEnabled(level)) return;
|
|
425
|
+
const notification = { jsonrpc: JSONRPC, method: 'notifications/message', params: { level, logger, data: text } };
|
|
426
|
+
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
427
|
+
else if (this.pendingNotifications.length < MAX_PENDING_NOTIFICATIONS) this.pendingNotifications.push(notification);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Shared spawn options for initial start and self-healing restarts, so the
|
|
431
|
+
// replacement child behaves exactly like the one it replaces.
|
|
432
|
+
childSpawnOptions(destination) {
|
|
433
|
+
return {
|
|
434
|
+
env: this.env,
|
|
435
|
+
spawn: this.spawn,
|
|
436
|
+
args: this.childArgs?.(destination),
|
|
437
|
+
requestTimeoutMs: this.requestTimeoutMs,
|
|
438
|
+
log: message => this.eventSink(message),
|
|
439
|
+
onNotification: notification => {
|
|
440
|
+
if (notification.method === 'notifications/tools/list_changed') this.toolsCache = null;
|
|
441
|
+
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
442
|
+
else if (this.pendingNotifications.length < MAX_PENDING_NOTIFICATIONS) this.pendingNotifications.push(notification);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
289
445
|
}
|
|
290
446
|
|
|
291
447
|
start() {
|
|
292
448
|
if (this.started) return this;
|
|
293
449
|
this.started = true;
|
|
294
450
|
this.starting = (async () => {
|
|
295
|
-
|
|
451
|
+
// Destinations start concurrently (relay bind + child spawn); children
|
|
452
|
+
// are assembled in destination order, which keeps serverInfo's
|
|
453
|
+
// children[0] fallback and merged slug numbering deterministic.
|
|
454
|
+
const started = await Promise.all(this.destinations.map(originalDestination => (async () => {
|
|
296
455
|
let destination = originalDestination;
|
|
456
|
+
let relay;
|
|
297
457
|
try {
|
|
298
458
|
if (useBasDestinationRelay(originalDestination, this.env)) {
|
|
299
|
-
|
|
459
|
+
relay = createBasDestinationRelay(originalDestination, { env: this.env, log: message => this.eventSink(message) });
|
|
300
460
|
const originalClose = originalDestination.close;
|
|
301
461
|
const relayUrl = await relay.ready;
|
|
302
462
|
destination = {
|
|
@@ -307,31 +467,29 @@ export class MCPProxy {
|
|
|
307
467
|
stats: relay.stats
|
|
308
468
|
},
|
|
309
469
|
async close() {
|
|
310
|
-
|
|
311
|
-
|
|
470
|
+
try {
|
|
471
|
+
await originalClose?.();
|
|
472
|
+
} finally {
|
|
473
|
+
await relay.close();
|
|
474
|
+
}
|
|
312
475
|
}
|
|
313
476
|
};
|
|
314
|
-
this.
|
|
477
|
+
this.eventSink(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
|
|
315
478
|
}
|
|
316
|
-
this.
|
|
317
|
-
|
|
479
|
+
this.eventSink(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
|
|
480
|
+
return {
|
|
318
481
|
destination,
|
|
319
|
-
child: new Child(this.binary, destination,
|
|
320
|
-
|
|
321
|
-
spawn: this.spawn,
|
|
322
|
-
args: this.childArgs?.(destination),
|
|
323
|
-
log: this.log,
|
|
324
|
-
onNotification: notification => {
|
|
325
|
-
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
326
|
-
else this.pendingNotifications.push(notification);
|
|
327
|
-
}
|
|
328
|
-
})
|
|
329
|
-
});
|
|
482
|
+
child: new Child(this.binary, destination, this.childSpawnOptions(destination))
|
|
483
|
+
};
|
|
330
484
|
} catch (error) {
|
|
331
|
-
this.
|
|
485
|
+
this.eventSink(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`, 'error');
|
|
486
|
+
// A relay whose ready promise rejected still owns dispatchers.
|
|
487
|
+
if (relay && destination === originalDestination) await relay.close().catch(() => {});
|
|
332
488
|
void closeDestinationRoute(destination);
|
|
489
|
+
return null;
|
|
333
490
|
}
|
|
334
|
-
}
|
|
491
|
+
})()));
|
|
492
|
+
this.children = started.filter(Boolean);
|
|
335
493
|
if (!this.children.length) {
|
|
336
494
|
this.started = false;
|
|
337
495
|
for (const destination of this.destinations) void closeDestinationRoute(destination);
|
|
@@ -349,97 +507,164 @@ export class MCPProxy {
|
|
|
349
507
|
// Self-healing mode 3: transparently restart a crashed VSP child so an
|
|
350
508
|
// MCP tools/call does not permanently fail after a single crash. The
|
|
351
509
|
// restarted child re-initializes and re-registers tools before the call
|
|
352
|
-
// is retried once.
|
|
510
|
+
// is retried once. Concurrent triggers share one restart (no orphaned
|
|
511
|
+
// children), and a bounded budget within a rolling window stops a
|
|
512
|
+
// crash-looping child from respawning forever.
|
|
353
513
|
async restartChild(entry) {
|
|
354
514
|
if (this.shuttingDown) throw new Error('MCP proxy is shutting down');
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
515
|
+
entry.restartState ||= { inFlight: null, timestamps: [] };
|
|
516
|
+
const state = entry.restartState;
|
|
517
|
+
if (state.inFlight) return state.inFlight;
|
|
518
|
+
const now = Date.now();
|
|
519
|
+
state.timestamps = state.timestamps.filter(at => now - at < RESTART_WINDOW_MS);
|
|
520
|
+
if (state.timestamps.length >= MAX_RESTARTS_PER_WINDOW) {
|
|
521
|
+
throw new Error(`VSP child restart budget exhausted (${MAX_RESTARTS_PER_WINDOW} in ${RESTART_WINDOW_MS / 1000}s); surfacing the failure instead of respawning`);
|
|
522
|
+
}
|
|
523
|
+
state.inFlight = (async () => {
|
|
524
|
+
const name = entry.destination.name;
|
|
525
|
+
this.eventSink(`[${name}] VSP child crashed; self-healing restart in progress`, 'warning');
|
|
526
|
+
try {
|
|
527
|
+
await entry.child.close().catch(() => {});
|
|
528
|
+
const child = new Child(this.binary, entry.destination, this.childSpawnOptions(entry.destination));
|
|
529
|
+
entry.child = child;
|
|
530
|
+
entry.server = await child.initialize(this.lastInitializeParams || {});
|
|
531
|
+
this.toolsCache = null;
|
|
532
|
+
await child.listToolsCached().catch(() => {});
|
|
533
|
+
state.timestamps.push(Date.now());
|
|
534
|
+
this.eventSink(`[${name}] VSP child self-healing restart complete`);
|
|
535
|
+
this.emitToolsListChanged();
|
|
536
|
+
return entry;
|
|
537
|
+
} finally {
|
|
538
|
+
state.inFlight = null;
|
|
366
539
|
}
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
540
|
+
})();
|
|
541
|
+
return state.inFlight;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
emitToolsListChanged() {
|
|
545
|
+
// Emitted only after the replacement child's tool surface is warm so a
|
|
546
|
+
// client re-list sees fresh data; buffered until the client initialized.
|
|
547
|
+
const notification = { jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' };
|
|
548
|
+
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
549
|
+
else this.pendingNotifications.push(notification);
|
|
373
550
|
}
|
|
374
551
|
|
|
375
552
|
async initializeChildren(params) {
|
|
376
553
|
await this.starting;
|
|
377
554
|
this.lastInitializeParams = params;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
555
|
+
// Children initialize concurrently; healthy entries keep destination
|
|
556
|
+
// order so tool naming stays deterministic.
|
|
557
|
+
const initialized = await Promise.all(this.children.map(async entry => {
|
|
558
|
+
const startedAt = Date.now();
|
|
559
|
+
this.eventSink(`[${entry.destination.name}] initializing VSP MCP session`);
|
|
381
560
|
try {
|
|
382
561
|
entry.server = await entry.child.initialize(params);
|
|
383
|
-
this.
|
|
384
|
-
|
|
562
|
+
this.eventSink(`[${entry.destination.name}] VSP MCP session initialized (${Date.now() - startedAt}ms)`);
|
|
563
|
+
return entry;
|
|
385
564
|
} catch (error) {
|
|
386
|
-
this.
|
|
565
|
+
this.eventSink(`[${entry.destination.name}] initialization failed: ${diagnosticText(error.message)}`, 'error');
|
|
387
566
|
await entry.child.close();
|
|
388
567
|
await closeDestinationRoute(entry.destination);
|
|
568
|
+
return null;
|
|
389
569
|
}
|
|
390
|
-
}
|
|
570
|
+
}));
|
|
571
|
+
const healthy = initialized.filter(Boolean);
|
|
391
572
|
this.children = healthy;
|
|
392
573
|
if (!healthy.length) throw new Error('No destination child initialized successfully');
|
|
393
574
|
this.initialized = true;
|
|
575
|
+
// Warm the merged tool cache so the client's first tools/list after
|
|
576
|
+
// initialize resolves without a child round trip.
|
|
577
|
+
const warmedAt = Date.now();
|
|
578
|
+
void this.mergedTools()
|
|
579
|
+
.then(tools => this.eventSink(`[MCP] tools cache warmed (${tools.length} tools, ${Date.now() - warmedAt}ms)`))
|
|
580
|
+
.catch(() => {});
|
|
394
581
|
return healthy;
|
|
395
582
|
}
|
|
396
583
|
|
|
397
|
-
|
|
398
|
-
this.
|
|
399
|
-
|
|
584
|
+
toolsCacheValid() {
|
|
585
|
+
const cached = this.toolsCache;
|
|
586
|
+
if (!cached) return false;
|
|
587
|
+
if (cached.children.length !== this.children.length) return false;
|
|
588
|
+
return this.children.every((entry, index) => entry.child === cached.children[index] && !entry.child.exited);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
mergedTools() {
|
|
592
|
+
if (this.mergedToolsPromise) return this.mergedToolsPromise;
|
|
593
|
+
if (this.toolsCacheValid()) return Promise.resolve([...this.toolsCache.tools]);
|
|
594
|
+
const childrenAtBuild = this.children.map(entry => entry.child);
|
|
595
|
+
// A build whose child listing failed is incomplete; caching it would pin
|
|
596
|
+
// the destination's missing tools until a restart or list_changed.
|
|
597
|
+
const buildState = { complete: true };
|
|
598
|
+
this.mergedToolsPromise = this.buildMergedTools(buildState)
|
|
599
|
+
.then(tools => {
|
|
600
|
+
// Cache only when the child set is unchanged; a restart that landed
|
|
601
|
+
// mid-build invalidates the result and the next list rebuilds.
|
|
602
|
+
if (!this.shuttingDown && buildState.complete && this.children.length === childrenAtBuild.length
|
|
603
|
+
&& this.children.every((entry, index) => entry.child === childrenAtBuild[index])) {
|
|
604
|
+
this.toolsCache = { children: childrenAtBuild, tools };
|
|
605
|
+
}
|
|
606
|
+
return [...tools];
|
|
607
|
+
})
|
|
608
|
+
.finally(() => { this.mergedToolsPromise = undefined; });
|
|
609
|
+
return this.mergedToolsPromise;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async buildMergedTools(buildState = { complete: true }) {
|
|
613
|
+
const namespace = new Map();
|
|
614
|
+
const usedSlugs = new Map();
|
|
400
615
|
const merged = [];
|
|
616
|
+
const publish = (name, mapping, definition) => {
|
|
617
|
+
// A duplicate public name must not shadow the first registration: the
|
|
618
|
+
// namespace map would keep the first handler while advertising both.
|
|
619
|
+
if (namespace.has(name)) {
|
|
620
|
+
this.eventSink(`[MCP] skipped duplicate public tool name: ${name}`, 'warning');
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
namespace.set(name, mapping);
|
|
624
|
+
merged.push(definition);
|
|
625
|
+
};
|
|
401
626
|
for (const entry of this.children) {
|
|
402
|
-
const slug = slugifyDestination(entry.destination.name,
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
627
|
+
const slug = slugifyDestination(entry.destination.name, usedSlugs);
|
|
628
|
+
const publicName = name => `${slug}_${publicToolSegment(name)}`;
|
|
629
|
+
const lintName = publicName(ABAP_LINT_TOOL.name);
|
|
630
|
+
publish(lintName, { handler: runABAPLint }, { ...ABAP_LINT_TOOL, name: lintName });
|
|
406
631
|
try {
|
|
407
|
-
const upstreamTools = await entry.child.
|
|
632
|
+
const upstreamTools = await entry.child.listToolsCached();
|
|
408
633
|
for (const tool of upstreamTools) {
|
|
409
634
|
if (tool.name === 'SAP') {
|
|
410
|
-
const applicationLogName =
|
|
411
|
-
|
|
635
|
+
const applicationLogName = publicName('GetApplicationLog');
|
|
636
|
+
publish(applicationLogName, {
|
|
412
637
|
entry,
|
|
413
638
|
upstream: 'SAP',
|
|
414
639
|
publicName: 'GetApplicationLog',
|
|
415
640
|
transformArguments: applicationLogArguments
|
|
416
|
-
}
|
|
417
|
-
merged.push({
|
|
641
|
+
}, {
|
|
418
642
|
name: applicationLogName,
|
|
419
643
|
description: `${APPLICATION_LOG_DESCRIPTION} [destination: ${entry.destination.name}]`,
|
|
420
644
|
inputSchema: APPLICATION_LOG_SCHEMA
|
|
421
645
|
});
|
|
422
646
|
}
|
|
423
647
|
|
|
424
|
-
if (!exposeVspTool(tool)) continue;
|
|
425
|
-
const name =
|
|
426
|
-
|
|
427
|
-
merged.push({ ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
|
|
648
|
+
if (!exposeVspTool(tool, this.readOnly)) continue;
|
|
649
|
+
const name = publicName(tool.name);
|
|
650
|
+
publish(name, { entry, upstream: tool.name }, { ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
|
|
428
651
|
}
|
|
429
652
|
for (const localTool of createEngineeringTools(entry, upstreamTools, { env: this.env, log: this.log })) {
|
|
430
|
-
const name =
|
|
431
|
-
|
|
432
|
-
merged.push({
|
|
653
|
+
const name = publicName(localTool.definition.name);
|
|
654
|
+
publish(name, { handler: localTool.handler }, {
|
|
433
655
|
...localTool.definition,
|
|
434
656
|
name,
|
|
435
657
|
description: `${localTool.definition.description} [destination: ${entry.destination.name}]`
|
|
436
658
|
});
|
|
437
659
|
}
|
|
438
660
|
} catch (error) {
|
|
439
|
-
|
|
661
|
+
buildState.complete = false;
|
|
662
|
+
this.eventSink(`[${entry.destination.name}] tools/list failed: ${redactText(error.message)}`, 'error');
|
|
440
663
|
}
|
|
441
664
|
}
|
|
442
665
|
if (!merged.length && this.children.length) throw new Error('No destination child provided tools');
|
|
666
|
+
// Swap atomically so a concurrent tools/call never sees a partial map.
|
|
667
|
+
this.namespace = namespace;
|
|
443
668
|
return merged;
|
|
444
669
|
}
|
|
445
670
|
|
|
@@ -449,15 +674,30 @@ export class MCPProxy {
|
|
|
449
674
|
return null;
|
|
450
675
|
}
|
|
451
676
|
if (message.id === undefined) return null;
|
|
677
|
+
const receivedAt = Date.now();
|
|
452
678
|
try {
|
|
679
|
+
this.eventSink(`[MCP] request ${message.method || '?'} (id=${message.id})`, 'debug');
|
|
453
680
|
if (message.method === 'initialize') {
|
|
454
681
|
await this.initializeChildren(message.params || {});
|
|
455
|
-
|
|
682
|
+
this.eventSink('[MCP] initialize complete; server ready', 'info');
|
|
683
|
+
return rpcResult(message.id, { protocolVersion: message.params?.protocolVersion || '2024-11-05', capabilities: { tools: { listChanged: true } }, serverInfo: { name: slugifyDestination(brandedEnvValue(this.env, 'DESTINATION') || this.children[0].destination.name), version: this.version } });
|
|
456
684
|
}
|
|
457
685
|
if (!this.initialized) return rpcError(message.id, -32002, 'MCP proxy is not initialized');
|
|
458
|
-
if (message.method === '
|
|
686
|
+
if (message.method === 'logging/setLevel' && MCP_LOG_LEVELS.includes(message.params?.level)) {
|
|
687
|
+
this.mcpLogLevel = message.params.level;
|
|
688
|
+
this.eventSink(`[MCP] log level set to ${this.mcpLogLevel}`);
|
|
689
|
+
}
|
|
690
|
+
if (message.method === 'tools/list') {
|
|
691
|
+
const tools = await this.mergedTools();
|
|
692
|
+
this.eventSink(`[MCP] tools/list returned ${tools.length} tools in ${Date.now() - receivedAt}ms`, 'debug');
|
|
693
|
+
return rpcResult(message.id, { tools });
|
|
694
|
+
}
|
|
459
695
|
if (message.method === 'tools/call') {
|
|
460
696
|
const name = message.params?.name;
|
|
697
|
+
// Concurrent dispatch means a tools/call can race the first
|
|
698
|
+
// tools/list that builds the tool namespace; ensure the surface
|
|
699
|
+
// exists before resolving the name.
|
|
700
|
+
if (!this.namespace.size) await this.mergedTools().catch(() => {});
|
|
461
701
|
const mapped = this.namespace.get(name);
|
|
462
702
|
if (!mapped) return rpcError(message.id, -32602, `Unknown namespaced tool: ${name}`);
|
|
463
703
|
if (mapped.handler) return rpcResult(message.id, await mapped.handler(message.params?.arguments));
|
|
@@ -465,31 +705,38 @@ export class MCPProxy {
|
|
|
465
705
|
const upstreamParams = { ...message.params, name: mapped.upstream };
|
|
466
706
|
if (mapped.transformArguments) upstreamParams.arguments = mapped.transformArguments(message.params?.arguments);
|
|
467
707
|
const startedAt = Date.now();
|
|
468
|
-
this.
|
|
708
|
+
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} started`);
|
|
469
709
|
try {
|
|
470
710
|
const response = await mapped.entry.child.request('tools/call', upstreamParams);
|
|
471
711
|
if (response.result?.isError) {
|
|
472
712
|
const detail = Array.isArray(response.result.content)
|
|
473
713
|
? response.result.content.filter(item => item.type === 'text').map(item => item.text).join(' ')
|
|
474
714
|
: '';
|
|
475
|
-
this.
|
|
715
|
+
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} returned an error in ${Date.now() - startedAt}ms${detail ? `: ${diagnosticText(detail)}` : ''}`, 'warning');
|
|
476
716
|
} else {
|
|
477
|
-
this.
|
|
717
|
+
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} completed in ${Date.now() - startedAt}ms`);
|
|
478
718
|
}
|
|
479
719
|
return response.error ? { ...response, id: message.id } : rpcResult(message.id, response.result);
|
|
480
720
|
} catch (error) {
|
|
481
|
-
this.
|
|
721
|
+
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`, 'error');
|
|
482
722
|
// Self-healing: a dead child (crash, OOM, transient pipe break) is
|
|
483
723
|
// restarted and the call retried once before surfacing the error.
|
|
724
|
+
// State-changing tools are never retried: the child may have
|
|
725
|
+
// completed the write before dying, and a blind re-send would
|
|
726
|
+
// apply it twice.
|
|
484
727
|
const childBroken = mapped.entry.child.exited || !mapped.entry.child.process.stdin.writable;
|
|
485
|
-
|
|
728
|
+
const retriable = !NON_RETRIABLE_VSP_TOOLS.has(mapped.upstream);
|
|
729
|
+
if (!retriable) {
|
|
730
|
+
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} is state-changing; not retried after a child crash (possible duplicate write)`, 'warning');
|
|
731
|
+
}
|
|
732
|
+
if (childBroken && retriable && !this.shuttingDown) {
|
|
486
733
|
try {
|
|
487
734
|
await this.restartChild(mapped.entry);
|
|
488
|
-
this.
|
|
735
|
+
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
|
|
489
736
|
const retried = await mapped.entry.child.request('tools/call', upstreamParams);
|
|
490
737
|
return retried.error ? { ...retried, id: message.id } : rpcResult(message.id, retried.result);
|
|
491
738
|
} catch (restartError) {
|
|
492
|
-
this.
|
|
739
|
+
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} self-healing restart failed: ${diagnosticText(restartError.message)}`, 'error');
|
|
493
740
|
}
|
|
494
741
|
}
|
|
495
742
|
return error.rpcError ? rpcError(message.id, error.rpcError.code || -32001, error.rpcError.message || error.message, error.rpcError.data) : rpcError(message.id, -32001, `Destination ${mapped.entry.destination.name} failed: ${error.message}`);
|
|
@@ -498,11 +745,11 @@ export class MCPProxy {
|
|
|
498
745
|
if (!FORWARDED_METHODS.has(message.method)) return rpcError(message.id, -32601, `Method not found: ${message.method}`);
|
|
499
746
|
const responses = [];
|
|
500
747
|
for (const entry of this.children) {
|
|
501
|
-
try { responses.push((await entry.child.request(message.method, message.params)).result); } catch (error) { this.
|
|
748
|
+
try { responses.push((await entry.child.request(message.method, message.params)).result); } catch (error) { this.eventSink(`[${entry.destination.name}] ${message.method} failed: ${redactText(error.message)}`, 'warning'); }
|
|
502
749
|
}
|
|
503
750
|
return rpcResult(message.id, responses.length === 1 ? responses[0] : responses);
|
|
504
751
|
} catch (error) {
|
|
505
|
-
this.
|
|
752
|
+
this.eventSink(`[MCP] ${message.method || 'request'} failed: ${diagnosticText(error.message)}`, 'error');
|
|
506
753
|
return rpcError(message.id, -32000, redactText(error.message));
|
|
507
754
|
}
|
|
508
755
|
}
|
|
@@ -511,33 +758,74 @@ export class MCPProxy {
|
|
|
511
758
|
this.output = output;
|
|
512
759
|
this.start();
|
|
513
760
|
let buffer = '';
|
|
514
|
-
|
|
761
|
+
// Requests are dispatched concurrently so one slow tools/call (an ATC
|
|
762
|
+
// run, a big query) cannot block ping, tools/list, or notifications and
|
|
763
|
+
// make the host declare the server unresponsive. JSON-RPC ids make
|
|
764
|
+
// response order irrelevant to the client; only the initialize handshake
|
|
765
|
+
// must finish first, so every request awaits the gate captured at parse
|
|
766
|
+
// time. In-flight requests are awaited on stream end so their responses
|
|
767
|
+
// are still written before shutdown.
|
|
768
|
+
let initGate = Promise.resolve();
|
|
769
|
+
const inFlight = new Set();
|
|
770
|
+
const dispatch = async message => {
|
|
771
|
+
try {
|
|
772
|
+
const response = await this.handle(message);
|
|
773
|
+
if (!response) return;
|
|
774
|
+
output(JSON.stringify(response));
|
|
775
|
+
if (message.method === 'initialize') {
|
|
776
|
+
this.clientInitialized = true;
|
|
777
|
+
for (const notification of this.pendingNotifications) output(JSON.stringify(notification));
|
|
778
|
+
this.pendingNotifications.length = 0;
|
|
779
|
+
}
|
|
780
|
+
} catch (error) {
|
|
781
|
+
// One failed dispatch must never poison the stream: log and keep
|
|
782
|
+
// serving whatever the client sends next.
|
|
783
|
+
this.eventSink(`[MCP] failed to answer client message: ${diagnosticText(error.message)}`, 'error');
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
const track = promise => {
|
|
787
|
+
inFlight.add(promise);
|
|
788
|
+
promise.finally(() => inFlight.delete(promise)).catch(() => {});
|
|
789
|
+
};
|
|
790
|
+
const onData = chunk => {
|
|
515
791
|
buffer += chunk;
|
|
516
792
|
let newline;
|
|
517
793
|
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
518
794
|
const line = buffer.slice(0, newline).trim();
|
|
519
795
|
buffer = buffer.slice(newline + 1);
|
|
520
796
|
if (!line) continue;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
797
|
+
let message;
|
|
798
|
+
try { message = JSON.parse(line); }
|
|
799
|
+
catch (error) {
|
|
800
|
+
this.eventSink(`[MCP] invalid client message dropped: ${redactText(error.message)}`, 'error');
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (message?.method === 'initialize' && message.id !== undefined) {
|
|
804
|
+
// Serialize the handshake itself so a second initialize cannot
|
|
805
|
+
// race the first into initializeChildren.
|
|
806
|
+
const run = initGate.then(() => dispatch(message));
|
|
807
|
+
initGate = run;
|
|
808
|
+
track(run);
|
|
809
|
+
} else if (message?.id !== undefined) {
|
|
810
|
+
track(initGate.then(() => dispatch(message)));
|
|
811
|
+
} else {
|
|
812
|
+
track(dispatch(message));
|
|
813
|
+
}
|
|
533
814
|
}
|
|
534
815
|
};
|
|
535
816
|
input.setEncoding('utf8');
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
817
|
+
input.on('error', error => {
|
|
818
|
+
this.eventSink(`[MCP] client input stream failed: ${diagnosticText(error.message)}`, 'error');
|
|
819
|
+
void this.close();
|
|
820
|
+
});
|
|
821
|
+
input.on('data', onData);
|
|
822
|
+
try {
|
|
823
|
+
await once(input, 'end');
|
|
824
|
+
await Promise.allSettled([...inFlight]);
|
|
825
|
+
} finally {
|
|
826
|
+
input.off('data', onData);
|
|
827
|
+
await this.close();
|
|
828
|
+
}
|
|
541
829
|
}
|
|
542
830
|
|
|
543
831
|
async close() {
|