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.
Files changed (57) hide show
  1. package/.github/agents/abap-developer.agent.md +3 -3
  2. package/.github/agents/abap-runtime-debugger.agent.md +3 -3
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
  4. package/.github/agents/rap-service-developer.agent.md +1 -1
  5. package/.github/agents/sap-solution-architect.agent.md +1 -1
  6. package/.github/skills/abap-debugging/SKILL.md +3 -3
  7. package/.github/skills/abap-development/SKILL.md +1 -1
  8. package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
  9. package/.github/skills/abap-testing-quality/SKILL.md +1 -1
  10. package/.github/skills/cds-development/SKILL.md +1 -1
  11. package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
  12. package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
  13. package/.github/skills/hana-cloud-native-development/SKILL.md +2 -2
  14. package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
  15. package/.github/skills/rap-development/SKILL.md +1 -1
  16. package/.github/skills/rap-service-delivery/SKILL.md +1 -1
  17. package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
  18. package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
  19. package/.github/skills/sap-transport-release/SKILL.md +1 -1
  20. package/README.md +17 -11
  21. package/inventory.md +179 -0
  22. package/package.json +2 -2
  23. package/scripts/build-vsp.mjs +2 -1
  24. package/scripts/postinstall.mjs +17 -18
  25. package/src/bas-destination-relay.mjs +58 -22
  26. package/src/bas-discovery.mjs +24 -8
  27. package/src/binary.mjs +101 -17
  28. package/src/cf-destination.mjs +55 -1
  29. package/src/credentials-store.mjs +0 -10
  30. package/src/engineering-tools.mjs +32 -10
  31. package/src/hana-tools.mjs +9 -2
  32. package/src/launcher.mjs +22 -19
  33. package/src/mcp-config.mjs +41 -7
  34. package/src/mcp-proxy.mjs +392 -104
  35. package/src/redact.mjs +28 -0
  36. package/src/setup.mjs +33 -3
  37. package/tools.md +4 -2
  38. package/test/bas-destination-relay.test.mjs +0 -374
  39. package/test/cf-connectivity.test.mjs +0 -287
  40. package/test/cf-destination.test.mjs +0 -307
  41. package/test/cf-runtime.test.mjs +0 -527
  42. package/test/copilot-assets.test.mjs +0 -63
  43. package/test/copilot-content.test.mjs +0 -249
  44. package/test/credentials-store.test.mjs +0 -187
  45. package/test/discovery.test.mjs +0 -257
  46. package/test/fixtures/fake-vsp.mjs +0 -263
  47. package/test/hana-config.test.mjs +0 -168
  48. package/test/hana-inspector-stdio.test.mjs +0 -44
  49. package/test/hana-tools.test.mjs +0 -206
  50. package/test/launcher.test.mjs +0 -399
  51. package/test/live-s4h.test.mjs +0 -202
  52. package/test/mcp-config-cf.test.mjs +0 -407
  53. package/test/mcp-proxy.test.mjs +0 -409
  54. package/test/pty.mjs +0 -8
  55. package/test/setup-cf.test.mjs +0 -489
  56. package/test/setup.test.mjs +0 -417
  57. 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
- return PUBLIC_VSP_TOOLS.has(tool?.name);
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('--enable-transports');
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
- try { this.onMessage(JSON.parse(line)); } catch (error) { this.fail(new Error(`invalid child JSON-RPC response: ${error.message}`)); }
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
- this.pending.set(id, { resolve, reject });
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.usedSlugs = new Map();
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
- for (const originalDestination of this.destinations) {
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
- const relay = createBasDestinationRelay(originalDestination, { env: this.env, log: this.log });
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
- await originalClose?.();
311
- await relay.close();
470
+ try {
471
+ await originalClose?.();
472
+ } finally {
473
+ await relay.close();
474
+ }
312
475
  }
313
476
  };
314
- this.log(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
477
+ this.eventSink(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
315
478
  }
316
- this.log(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
317
- this.children.push({
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
- env: this.env,
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.log(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`);
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
- const name = entry.destination.name;
356
- this.log(`[${name}] VSP child crashed; self-healing restart in progress`);
357
- await entry.child.close().catch(() => {});
358
- const child = new Child(this.binary, entry.destination, {
359
- env: this.env,
360
- spawn: this.spawn,
361
- args: this.childArgs?.(entry.destination),
362
- log: this.log,
363
- onNotification: notification => {
364
- if (this.clientInitialized) this.output(JSON.stringify(notification));
365
- else this.pendingNotifications.push(notification);
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
- entry.child = child;
369
- entry.server = await child.initialize(this.lastInitializeParams || {});
370
- await child.listTools().catch(() => {});
371
- this.log(`[${name}] VSP child self-healing restart complete`);
372
- return entry;
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
- const healthy = [];
379
- for (const entry of this.children) {
380
- this.log(`[${entry.destination.name}] initializing VSP MCP session`);
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.log(`[${entry.destination.name}] VSP MCP session initialized`);
384
- healthy.push(entry);
562
+ this.eventSink(`[${entry.destination.name}] VSP MCP session initialized (${Date.now() - startedAt}ms)`);
563
+ return entry;
385
564
  } catch (error) {
386
- this.log(`[${entry.destination.name}] initialization failed: ${diagnosticText(error.message)}`);
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
- async mergedTools() {
398
- this.namespace.clear();
399
- this.usedSlugs.clear();
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, this.usedSlugs);
403
- const lintName = `${slug}__${ABAP_LINT_TOOL.name}`;
404
- this.namespace.set(lintName, { handler: runABAPLint });
405
- merged.push({ ...ABAP_LINT_TOOL, name: lintName });
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.listTools();
632
+ const upstreamTools = await entry.child.listToolsCached();
408
633
  for (const tool of upstreamTools) {
409
634
  if (tool.name === 'SAP') {
410
- const applicationLogName = `${slug}__GetApplicationLog`;
411
- this.namespace.set(applicationLogName, {
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 = `${slug}__${tool.name}`;
426
- this.namespace.set(name, { entry, upstream: tool.name });
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 = `${slug}__${localTool.definition.name}`;
431
- this.namespace.set(name, { handler: localTool.handler });
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
- this.log(`[${entry.destination.name}] tools/list failed: ${redactText(error.message)}`);
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
- return rpcResult(message.id, { protocolVersion: message.params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: brandedEnvValue(this.env, 'DESTINATION') || this.children[0].destination.name, version: '0.1.0' } });
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 === 'tools/list') return rpcResult(message.id, { tools: await this.mergedTools() });
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.log(`[${mapped.entry.destination.name}] tools/call ${toolName} started`);
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.log(`[${mapped.entry.destination.name}] tools/call ${toolName} returned an error in ${Date.now() - startedAt}ms${detail ? `: ${diagnosticText(detail)}` : ''}`);
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.log(`[${mapped.entry.destination.name}] tools/call ${toolName} completed in ${Date.now() - startedAt}ms`);
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.log(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`);
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
- if (childBroken && !this.shuttingDown) {
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.log(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
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.log(`[${mapped.entry.destination.name}] tools/call ${toolName} self-healing restart failed: ${diagnosticText(restartError.message)}`);
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.log(`[${entry.destination.name}] ${message.method} failed: ${redactText(error.message)}`); }
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.log(`[MCP] ${message.method || 'request'} failed: ${diagnosticText(error.message)}`);
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
- const onData = async chunk => {
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
- try {
522
- const message = JSON.parse(line);
523
- const response = await this.handle(message);
524
- if (response) {
525
- output(JSON.stringify(response));
526
- if (message.method === 'initialize') {
527
- this.clientInitialized = true;
528
- for (const notification of this.pendingNotifications) output(JSON.stringify(notification));
529
- this.pendingNotifications.length = 0;
530
- }
531
- }
532
- } catch (error) { this.log(redactText(error.message)); }
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
- let queue = Promise.resolve();
537
- input.on('data', chunk => { queue = queue.then(() => onData(chunk)); });
538
- await once(input, 'end');
539
- await queue;
540
- await this.close();
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() {