sap-ai-dev-toolkit 0.5.5 → 0.5.7

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 +8 -8
  2. package/.github/agents/abap-runtime-debugger.agent.md +6 -6
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
  4. package/.github/agents/rap-service-developer.agent.md +5 -5
  5. package/.github/agents/sap-solution-architect.agent.md +2 -2
  6. package/.github/skills/abap-debugging/SKILL.md +3 -3
  7. package/.github/skills/abap-development/SKILL.md +3 -3
  8. package/.github/skills/abap-runtime-analysis/SKILL.md +4 -4
  9. package/.github/skills/abap-testing-quality/SKILL.md +3 -3
  10. package/.github/skills/cds-development/SKILL.md +3 -3
  11. package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
  12. package/.github/skills/hana-cloud-inspection/SKILL.md +2 -2
  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 +3 -3
  16. package/.github/skills/rap-service-delivery/SKILL.md +2 -2
  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 +2 -2
  20. package/README.md +30 -23
  21. package/inventory.md +181 -0
  22. package/package.json +2 -2
  23. package/scripts/build-vsp.mjs +2 -1
  24. package/scripts/postinstall.mjs +14 -16
  25. package/src/bas-destination-relay.mjs +25 -3
  26. package/src/bas-discovery.mjs +9 -3
  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 +14 -13
  33. package/src/mcp-config.mjs +19 -1
  34. package/src/mcp-proxy.mjs +284 -103
  35. package/src/redact.mjs +28 -0
  36. package/src/setup.mjs +30 -1
  37. package/tools.md +10 -2
  38. package/test/bas-destination-relay.test.mjs +0 -415
  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 -282
  46. package/test/fixtures/fake-vsp.mjs +0 -266
  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 -401
  51. package/test/live-s4h.test.mjs +0 -204
  52. package/test/mcp-config-cf.test.mjs +0 -475
  53. package/test/mcp-proxy.test.mjs +0 -604
  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,6 +5,7 @@ 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']);
@@ -37,6 +38,51 @@ const APPLICATION_LOG_SCHEMA = {
37
38
  };
38
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.';
39
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
+
40
86
  // Keep the default public VSP surface small enough for developer-lifecycle use.
41
87
  // Hidden upstream tools can still be used by local workflow tools when needed.
42
88
  export const PUBLIC_VSP_TOOLS = new Set([
@@ -93,8 +139,11 @@ export const PUBLIC_VSP_TOOLS = new Set([
93
139
  'WriteSource'
94
140
  ]);
95
141
 
96
- function exposeVspTool(tool) {
97
- 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));
98
147
  }
99
148
 
100
149
  // Public tool names are lowercase snake_case. BAS/VS Code chat tool references
@@ -108,10 +157,7 @@ export function snakeCaseName(value) {
108
157
  }
109
158
 
110
159
  // Every generated tool is prefixed with the destination it targets:
111
- // sap_system_<destination-slug>_<tool>, for example
112
- // sap_system_actions4d_get_table_contents.
113
- export const TOOL_NAME_PREFIX = 'sap_system_';
114
-
160
+ // <destination-slug>_<tool>, for example demo-abap_get_table_contents.
115
161
  function publicToolSegment(name) {
116
162
  return snakeCaseName(name) || String(name).toLowerCase();
117
163
  }
@@ -127,8 +173,6 @@ function applicationLogArguments(arguments_ = {}) {
127
173
 
128
174
  function rpcResult(id, result) { return { jsonrpc: JSONRPC, id, result }; }
129
175
  function rpcError(id, code, message, data) { return { jsonrpc: JSONRPC, id, error: { code, message, ...(data === undefined ? {} : { data }) } }; }
130
- function redactText(text) { return String(text).replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]'); }
131
- function diagnosticText(text) { return redactText(text || 'unknown error').replace(/\s+/g, ' ').slice(0, 500); }
132
176
 
133
177
  const closedRoutes = new WeakMap();
134
178
 
@@ -171,7 +215,8 @@ export function childArguments(destination, env = process.env) {
171
215
  const mode = brandedEnvValue(env, 'MODE') || 'expert';
172
216
  const args = ['--url', destination.url, '--client', destination.client || '001', '--mode', mode];
173
217
  if (destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation') args.push('--proxy-auth');
174
- args.push('--enable-transports');
218
+ if (readOnlyMode(env)) args.push('--transport-read-only');
219
+ else args.push('--enable-transports');
175
220
  return args;
176
221
  }
177
222
 
@@ -184,6 +229,7 @@ class Child {
184
229
  this.buffer = '';
185
230
  this.exited = false;
186
231
  this.closing = false;
232
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
187
233
  this.process = (options.spawn || nodeSpawn)(binary, options.args || childArguments(destination, options.env), {
188
234
  env: childEnvironment(destination, options.env),
189
235
  stdio: ['pipe', 'pipe', 'pipe']
@@ -194,6 +240,13 @@ class Child {
194
240
  this.process.stderr.on('data', chunk => {
195
241
  if (options.log) options.log(`[${destination.name}] ${redactText(chunk).trimEnd()}`);
196
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
+ });
197
250
  this.process.on('error', error => {
198
251
  if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
199
252
  this.fail(error);
@@ -210,12 +263,21 @@ class Child {
210
263
 
211
264
  onData(chunk) {
212
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
+ }
213
271
  let newline;
214
272
  while ((newline = this.buffer.indexOf('\n')) >= 0) {
215
273
  const line = this.buffer.slice(0, newline).trim();
216
274
  this.buffer = this.buffer.slice(newline + 1);
217
275
  if (!line) continue;
218
- 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)}`); }
219
281
  }
220
282
  }
221
283
 
@@ -250,9 +312,22 @@ class Child {
250
312
  if (this.exited || !this.process.stdin.writable) return Promise.reject(new Error(`destination ${this.destination.name} child is not running`));
251
313
  const id = this.nextId++;
252
314
  return new Promise((resolve, reject) => {
253
- 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
+ }
254
329
  try { this.process.stdin.write(`${JSON.stringify({ jsonrpc: JSONRPC, id, method, ...(params === undefined ? {} : { params })})}\n`); }
255
- catch (error) { this.pending.delete(id); reject(error); }
330
+ catch (error) { cancel(); this.pending.delete(id); reject(error); }
256
331
  });
257
332
  }
258
333
 
@@ -310,7 +385,7 @@ class Child {
310
385
  }
311
386
 
312
387
  export class MCPProxy {
313
- 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' }) {
314
389
  this.binary = binary;
315
390
  this.destinations = destinations;
316
391
  this.env = env;
@@ -318,6 +393,9 @@ export class MCPProxy {
318
393
  this.childArgs = childArgs;
319
394
  this.log = log;
320
395
  this.output = output;
396
+ this.version = version;
397
+ this.readOnly = readOnlyMode(env);
398
+ this.requestTimeoutMs = requestTimeoutMs(env);
321
399
  this.children = [];
322
400
  this.started = false;
323
401
  this.namespace = new Map();
@@ -338,13 +416,32 @@ export class MCPProxy {
338
416
  // notifications/message log event, in the host's MCP server output channel
339
417
  // (VS Code/BAS and Claude Code both render these). Events raised before the
340
418
  // client initializes are buffered so the channel still shows the full
341
- // startup picture once it opens.
419
+ // startup picture once it opens. Everything passes through redactText so no
420
+ // lane can leak a credential that reached a diagnostic string.
342
421
  eventSink(message, level = 'info', logger = 'sap-ai-dev-toolkit') {
343
- this.log(message);
422
+ const text = redactText(message);
423
+ this.log(text);
344
424
  if (!this.mcpLogEnabled(level)) return;
345
- const notification = { jsonrpc: JSONRPC, method: 'notifications/message', params: { level, logger, data: message } };
425
+ const notification = { jsonrpc: JSONRPC, method: 'notifications/message', params: { level, logger, data: text } };
346
426
  if (this.clientInitialized) this.output(JSON.stringify(notification));
347
- else this.pendingNotifications.push(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
+ };
348
445
  }
349
446
 
350
447
  start() {
@@ -370,8 +467,11 @@ export class MCPProxy {
370
467
  stats: relay.stats
371
468
  },
372
469
  async close() {
373
- await originalClose?.();
374
- await relay.close();
470
+ try {
471
+ await originalClose?.();
472
+ } finally {
473
+ await relay.close();
474
+ }
375
475
  }
376
476
  };
377
477
  this.eventSink(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
@@ -379,17 +479,7 @@ export class MCPProxy {
379
479
  this.eventSink(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
380
480
  return {
381
481
  destination,
382
- child: new Child(this.binary, destination, {
383
- env: this.env,
384
- spawn: this.spawn,
385
- args: this.childArgs?.(destination),
386
- log: message => this.eventSink(message),
387
- onNotification: notification => {
388
- if (notification.method === 'notifications/tools/list_changed') this.toolsCache = null;
389
- if (this.clientInitialized) this.output(JSON.stringify(notification));
390
- else this.pendingNotifications.push(notification);
391
- }
392
- })
482
+ child: new Child(this.binary, destination, this.childSpawnOptions(destination))
393
483
  };
394
484
  } catch (error) {
395
485
  this.eventSink(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`, 'error');
@@ -417,30 +507,38 @@ export class MCPProxy {
417
507
  // Self-healing mode 3: transparently restart a crashed VSP child so an
418
508
  // MCP tools/call does not permanently fail after a single crash. The
419
509
  // restarted child re-initializes and re-registers tools before the call
420
- // 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.
421
513
  async restartChild(entry) {
422
514
  if (this.shuttingDown) throw new Error('MCP proxy is shutting down');
423
- const name = entry.destination.name;
424
- this.eventSink(`[${name}] VSP child crashed; self-healing restart in progress`, 'warning');
425
- await entry.child.close().catch(() => {});
426
- const child = new Child(this.binary, entry.destination, {
427
- env: this.env,
428
- spawn: this.spawn,
429
- args: this.childArgs?.(entry.destination),
430
- log: message => this.eventSink(message),
431
- onNotification: notification => {
432
- if (notification.method === 'notifications/tools/list_changed') this.toolsCache = null;
433
- if (this.clientInitialized) this.output(JSON.stringify(notification));
434
- 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;
435
539
  }
436
- });
437
- entry.child = child;
438
- entry.server = await child.initialize(this.lastInitializeParams || {});
439
- this.toolsCache = null;
440
- await child.listToolsCached().catch(() => {});
441
- this.eventSink(`[${name}] VSP child self-healing restart complete`);
442
- this.emitToolsListChanged();
443
- return entry;
540
+ })();
541
+ return state.inFlight;
444
542
  }
445
543
 
446
544
  emitToolsListChanged() {
@@ -494,11 +592,14 @@ export class MCPProxy {
494
592
  if (this.mergedToolsPromise) return this.mergedToolsPromise;
495
593
  if (this.toolsCacheValid()) return Promise.resolve([...this.toolsCache.tools]);
496
594
  const childrenAtBuild = this.children.map(entry => entry.child);
497
- this.mergedToolsPromise = this.buildMergedTools()
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)
498
599
  .then(tools => {
499
600
  // Cache only when the child set is unchanged; a restart that landed
500
601
  // mid-build invalidates the result and the next list rebuilds.
501
- if (!this.shuttingDown && this.children.length === childrenAtBuild.length
602
+ if (!this.shuttingDown && buildState.complete && this.children.length === childrenAtBuild.length
502
603
  && this.children.every((entry, index) => entry.child === childrenAtBuild[index])) {
503
604
  this.toolsCache = { children: childrenAtBuild, tools };
504
605
  }
@@ -508,10 +609,17 @@ export class MCPProxy {
508
609
  return this.mergedToolsPromise;
509
610
  }
510
611
 
511
- async buildMergedTools() {
612
+ async buildMergedTools(buildState = { complete: true }) {
512
613
  const namespace = new Map();
513
614
  const usedSlugs = new Map();
514
615
  const merged = [];
616
+ // Generated MCP entries pin exactly one destination per server, so tool
617
+ // names are unprefixed (run_query, get_source): the server name already
618
+ // identifies the target and a slug prefix would be redundant in chat
619
+ // references. Only when one server fronts several destinations (manual
620
+ // multi-destination startup) does each name carry its destination slug
621
+ // so every tool stays unambiguous.
622
+ const prefixed = this.children.length > 1;
515
623
  const publish = (name, mapping, definition) => {
516
624
  // A duplicate public name must not shadow the first registration: the
517
625
  // namespace map would keep the first handler while advertising both.
@@ -524,40 +632,54 @@ export class MCPProxy {
524
632
  };
525
633
  for (const entry of this.children) {
526
634
  const slug = slugifyDestination(entry.destination.name, usedSlugs);
527
- const publicName = name => `${TOOL_NAME_PREFIX}${slug}_${publicToolSegment(name)}`;
635
+ const publicName = prefixed ? (name => `${slug}_${publicToolSegment(name)}`) : (name => publicToolSegment(name));
528
636
  const lintName = publicName(ABAP_LINT_TOOL.name);
529
637
  publish(lintName, { handler: runABAPLint }, { ...ABAP_LINT_TOOL, name: lintName });
638
+ let upstreamTools;
530
639
  try {
531
- const upstreamTools = await entry.child.listToolsCached();
532
- for (const tool of upstreamTools) {
533
- if (tool.name === 'SAP') {
534
- const applicationLogName = publicName('GetApplicationLog');
535
- publish(applicationLogName, {
536
- entry,
537
- upstream: 'SAP',
538
- publicName: 'GetApplicationLog',
539
- transformArguments: applicationLogArguments
540
- }, {
541
- name: applicationLogName,
542
- description: `${APPLICATION_LOG_DESCRIPTION} [destination: ${entry.destination.name}]`,
543
- inputSchema: APPLICATION_LOG_SCHEMA
544
- });
545
- }
546
-
547
- if (!exposeVspTool(tool)) continue;
548
- const name = publicName(tool.name);
549
- publish(name, { entry, upstream: tool.name }, { ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
550
- }
551
- for (const localTool of createEngineeringTools(entry, upstreamTools, { env: this.env, log: this.log })) {
552
- const name = publicName(localTool.definition.name);
553
- publish(name, { handler: localTool.handler }, {
554
- ...localTool.definition,
555
- name,
556
- description: `${localTool.definition.description} [destination: ${entry.destination.name}]`
557
- });
558
- }
640
+ upstreamTools = await entry.child.listToolsCached();
641
+ entry.lastUpstreamTools = upstreamTools;
559
642
  } catch (error) {
643
+ buildState.complete = false;
560
644
  this.eventSink(`[${entry.destination.name}] tools/list failed: ${redactText(error.message)}`, 'error');
645
+ // A crashed child must not silently remove its tools from the
646
+ // surface: fall back to the last successful listing so the names stay
647
+ // callable and the tools/call self-heal path can restart the child.
648
+ upstreamTools = entry.lastUpstreamTools;
649
+ }
650
+ if (!upstreamTools) continue;
651
+ for (const tool of upstreamTools) {
652
+ if (tool.name === 'SAP') {
653
+ const applicationLogName = publicName('GetApplicationLog');
654
+ publish(applicationLogName, {
655
+ entry,
656
+ upstream: 'SAP',
657
+ publicName: 'GetApplicationLog',
658
+ transformArguments: applicationLogArguments
659
+ }, {
660
+ name: applicationLogName,
661
+ description: `${APPLICATION_LOG_DESCRIPTION} [destination: ${entry.destination.name}]`,
662
+ inputSchema: APPLICATION_LOG_SCHEMA
663
+ });
664
+ }
665
+
666
+ if (!exposeVspTool(tool, this.readOnly)) continue;
667
+ const name = publicName(tool.name);
668
+ publish(name, { entry, upstream: tool.name }, { ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
669
+ }
670
+ // Local workflow tools gate on the upstream surface they can use; in
671
+ // read-only mode the hidden write tools must not enable change-set
672
+ // staging either.
673
+ const effectiveUpstream = this.readOnly
674
+ ? upstreamTools.filter(tool => !READ_ONLY_HIDDEN_VSP_TOOLS.has(tool.name))
675
+ : upstreamTools;
676
+ for (const localTool of createEngineeringTools(entry, effectiveUpstream, { env: this.env, log: this.log })) {
677
+ const name = publicName(localTool.definition.name);
678
+ publish(name, { handler: localTool.handler }, {
679
+ ...localTool.definition,
680
+ name,
681
+ description: `${localTool.definition.description} [destination: ${entry.destination.name}]`
682
+ });
561
683
  }
562
684
  }
563
685
  if (!merged.length && this.children.length) throw new Error('No destination child provided tools');
@@ -578,7 +700,7 @@ export class MCPProxy {
578
700
  if (message.method === 'initialize') {
579
701
  await this.initializeChildren(message.params || {});
580
702
  this.eventSink('[MCP] initialize complete; server ready', 'info');
581
- 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: '0.1.0' } });
703
+ 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 } });
582
704
  }
583
705
  if (!this.initialized) return rpcError(message.id, -32002, 'MCP proxy is not initialized');
584
706
  if (message.method === 'logging/setLevel' && MCP_LOG_LEVELS.includes(message.params?.level)) {
@@ -592,6 +714,10 @@ export class MCPProxy {
592
714
  }
593
715
  if (message.method === 'tools/call') {
594
716
  const name = message.params?.name;
717
+ // Concurrent dispatch means a tools/call can race the first
718
+ // tools/list that builds the tool namespace; ensure the surface
719
+ // exists before resolving the name.
720
+ if (!this.namespace.size) await this.mergedTools().catch(() => {});
595
721
  const mapped = this.namespace.get(name);
596
722
  if (!mapped) return rpcError(message.id, -32602, `Unknown namespaced tool: ${name}`);
597
723
  if (mapped.handler) return rpcResult(message.id, await mapped.handler(message.params?.arguments));
@@ -615,17 +741,31 @@ export class MCPProxy {
615
741
  this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`, 'error');
616
742
  // Self-healing: a dead child (crash, OOM, transient pipe break) is
617
743
  // restarted and the call retried once before surfacing the error.
744
+ // State-changing tools are never retried: the child may have
745
+ // completed the write before dying, and a blind re-send would
746
+ // apply it twice.
618
747
  const childBroken = mapped.entry.child.exited || !mapped.entry.child.process.stdin.writable;
619
- if (childBroken && !this.shuttingDown) {
748
+ const retriable = !NON_RETRIABLE_VSP_TOOLS.has(mapped.upstream);
749
+ if (!retriable) {
750
+ this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} is state-changing; not retried after a child crash (possible duplicate write)`, 'warning');
751
+ }
752
+ let restartFailure;
753
+ if (childBroken && retriable && !this.shuttingDown) {
620
754
  try {
621
755
  await this.restartChild(mapped.entry);
622
756
  this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
623
757
  const retried = await mapped.entry.child.request('tools/call', upstreamParams);
624
758
  return retried.error ? { ...retried, id: message.id } : rpcResult(message.id, retried.result);
625
759
  } catch (restartError) {
760
+ restartFailure = restartError;
626
761
  this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} self-healing restart failed: ${diagnosticText(restartError.message)}`, 'error');
627
762
  }
628
763
  }
764
+ if (restartFailure) {
765
+ // The restart refusal (budget exhausted, shutdown) is the
766
+ // actionable cause; keep the original failure as context.
767
+ return rpcError(message.id, -32001, `Destination ${mapped.entry.destination.name} failed: ${error.message}; self-healing restart did not run: ${restartFailure.message}`);
768
+ }
629
769
  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}`);
630
770
  }
631
771
  }
@@ -645,33 +785,74 @@ export class MCPProxy {
645
785
  this.output = output;
646
786
  this.start();
647
787
  let buffer = '';
648
- const onData = async chunk => {
788
+ // Requests are dispatched concurrently so one slow tools/call (an ATC
789
+ // run, a big query) cannot block ping, tools/list, or notifications and
790
+ // make the host declare the server unresponsive. JSON-RPC ids make
791
+ // response order irrelevant to the client; only the initialize handshake
792
+ // must finish first, so every request awaits the gate captured at parse
793
+ // time. In-flight requests are awaited on stream end so their responses
794
+ // are still written before shutdown.
795
+ let initGate = Promise.resolve();
796
+ const inFlight = new Set();
797
+ const dispatch = async message => {
798
+ try {
799
+ const response = await this.handle(message);
800
+ if (!response) return;
801
+ output(JSON.stringify(response));
802
+ if (message.method === 'initialize') {
803
+ this.clientInitialized = true;
804
+ for (const notification of this.pendingNotifications) output(JSON.stringify(notification));
805
+ this.pendingNotifications.length = 0;
806
+ }
807
+ } catch (error) {
808
+ // One failed dispatch must never poison the stream: log and keep
809
+ // serving whatever the client sends next.
810
+ this.eventSink(`[MCP] failed to answer client message: ${diagnosticText(error.message)}`, 'error');
811
+ }
812
+ };
813
+ const track = promise => {
814
+ inFlight.add(promise);
815
+ promise.finally(() => inFlight.delete(promise)).catch(() => {});
816
+ };
817
+ const onData = chunk => {
649
818
  buffer += chunk;
650
819
  let newline;
651
820
  while ((newline = buffer.indexOf('\n')) >= 0) {
652
821
  const line = buffer.slice(0, newline).trim();
653
822
  buffer = buffer.slice(newline + 1);
654
823
  if (!line) continue;
655
- try {
656
- const message = JSON.parse(line);
657
- const response = await this.handle(message);
658
- if (response) {
659
- output(JSON.stringify(response));
660
- if (message.method === 'initialize') {
661
- this.clientInitialized = true;
662
- for (const notification of this.pendingNotifications) output(JSON.stringify(notification));
663
- this.pendingNotifications.length = 0;
664
- }
665
- }
666
- } catch (error) { this.eventSink(`[MCP] invalid client message dropped: ${redactText(error.message)}`, 'error'); }
824
+ let message;
825
+ try { message = JSON.parse(line); }
826
+ catch (error) {
827
+ this.eventSink(`[MCP] invalid client message dropped: ${redactText(error.message)}`, 'error');
828
+ continue;
829
+ }
830
+ if (message?.method === 'initialize' && message.id !== undefined) {
831
+ // Serialize the handshake itself so a second initialize cannot
832
+ // race the first into initializeChildren.
833
+ const run = initGate.then(() => dispatch(message));
834
+ initGate = run;
835
+ track(run);
836
+ } else if (message?.id !== undefined) {
837
+ track(initGate.then(() => dispatch(message)));
838
+ } else {
839
+ track(dispatch(message));
840
+ }
667
841
  }
668
842
  };
669
843
  input.setEncoding('utf8');
670
- let queue = Promise.resolve();
671
- input.on('data', chunk => { queue = queue.then(() => onData(chunk)); });
672
- await once(input, 'end');
673
- await queue;
674
- await this.close();
844
+ input.on('error', error => {
845
+ this.eventSink(`[MCP] client input stream failed: ${diagnosticText(error.message)}`, 'error');
846
+ void this.close();
847
+ });
848
+ input.on('data', onData);
849
+ try {
850
+ await once(input, 'end');
851
+ await Promise.allSettled([...inFlight]);
852
+ } finally {
853
+ input.off('data', onData);
854
+ await this.close();
855
+ }
675
856
  }
676
857
 
677
858
  async close() {
package/src/redact.mjs ADDED
@@ -0,0 +1,28 @@
1
+ // Shared credential redaction for every diagnostic lane (stderr, MCP log
2
+ // notifications, tool errors, relay responses). One implementation so a fix
3
+ // applies everywhere; the old per-file copies each missed a different case.
4
+ //
5
+ // Covered patterns:
6
+ // - authorization: Basic <base64> / Bearer <token> / proxy-authorization
7
+ // - cookie: a=b; c=d (the whole pair list, not just the first pair)
8
+ // - password=... / secret=... / token=... / x-csrf-token: ... style keys
9
+ // - URLs with embedded userinfo, https://user:pass@host
10
+ // Bare credential blobs without a recognizable key cannot be detected
11
+ // generically and are out of scope.
12
+ const URL_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)([^\s/@:?=&]+):([^\s/@]+)@/gi;
13
+ const NAMED_VALUE = /((?:proxy-)?authorization|cookie|passwd|password|secret|token)\s*[:=]\s*(?:bearer\s+|basic\s+)?[^\s,;"']+/gi;
14
+ // Header-shaped values may carry lists (cookie pairs); after the first pass
15
+ // redacts the leading pair, this pass drops the remainder of the line.
16
+ const HEADER_TAIL = /^((?:proxy-)?authorization|cookie)\s*[:=].*$/gim;
17
+
18
+ export function redactText(value) {
19
+ return String(value ?? '')
20
+ .replace(URL_USERINFO, '$1[redacted]@')
21
+ .replace(NAMED_VALUE, '$1=[redacted]')
22
+ .replace(HEADER_TAIL, '$1=[redacted]');
23
+ }
24
+
25
+ // Redact, collapse whitespace, and clamp to a diagnostic-friendly length.
26
+ export function diagnosticText(value, limit = 500) {
27
+ return redactText(value?.message || value || 'unknown error').replace(/\s+/g, ' ').trim().slice(0, limit);
28
+ }
package/src/setup.mjs CHANGED
@@ -2,7 +2,7 @@ import { createInterface } from 'node:readline/promises';
2
2
  import { stdin, stdout } from 'node:process';
3
3
  import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
4
4
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
5
- import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys } from './cf-destination.mjs';
5
+ import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
6
6
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
7
7
  import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
8
8
  import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
@@ -224,6 +224,35 @@ export async function runSetup({
224
224
  if (!target.available) {
225
225
  warnings.push(target.reason);
226
226
  } else {
227
+ // Sweep for service keys this toolkit created but no config references
228
+ // (an interrupted earlier setup leaves them behind; a Destination key
229
+ // exposes credentials for every destination in its instance).
230
+ try {
231
+ const orphans = await findOrphanedCloudFoundryServiceKeys({ env, spaceGuid: target.spaceGuid, managedKeys });
232
+ if (orphans.length) {
233
+ print(output, '');
234
+ print(output, formatStatus(`Found ${orphans.length} unreferenced sap-ai-dev-toolkit service key${orphans.length === 1 ? '' : 's'} in this CF space:`, 'warning', output, 'Cloud Foundry'));
235
+ for (const orphan of orphans.slice(0, 5)) print(output, ` • ${orphan.keyName} on ${orphan.instanceName}`);
236
+ const readline = createInterface({ input, output });
237
+ let removeOrphans = false;
238
+ try {
239
+ const answer = await readline.question('Delete these unreferenced service keys? (y + Enter = delete; Enter = keep) ');
240
+ removeOrphans = /^y(?:es)?$/i.test(answer.trim());
241
+ } finally {
242
+ readline.close();
243
+ }
244
+ if (removeOrphans) {
245
+ const result = await deleteManagedCloudFoundryServiceKeys({ env, keys: orphans });
246
+ warnings.push(...(result.warnings || []));
247
+ const deleted = orphans.length - (result.warnings || []).length;
248
+ print(output, formatStatus(`Removed ${deleted} unreferenced service key${deleted === 1 ? '' : 's'}.`, 'success', output, 'Cloud Foundry'));
249
+ } else {
250
+ warnings.push(`${orphans.length} unreferenced sap-ai-dev-toolkit service key${orphans.length === 1 ? '' : 's'} were kept; review them with "cf service-keys" and delete manually if unwanted.`);
251
+ }
252
+ }
253
+ } catch {
254
+ // The sweep is best-effort; a failed lookup must not block setup.
255
+ }
227
256
  let shouldImport = false;
228
257
  try {
229
258
  shouldImport = await confirmCfImport({ input, output });