sap-ai-dev-toolkit 0.5.5 → 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 +6 -6
  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 +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 +8 -11
  33. package/src/mcp-config.mjs +19 -1
  34. package/src/mcp-proxy.mjs +230 -76
  35. package/src/redact.mjs +28 -0
  36. package/src/setup.mjs +30 -1
  37. package/tools.md +1 -1
  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/launcher.mjs CHANGED
@@ -5,12 +5,13 @@ import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
7
7
  import { findBinary } from './binary.mjs';
8
- import { MCPProxy, TOOL_NAME_PREFIX } from './mcp-proxy.mjs';
8
+ import { MCPProxy } from './mcp-proxy.mjs';
9
9
  import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
10
10
  import { runSetup } from './setup.mjs';
11
11
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
12
12
  import { withBrandedEnvironment } from './branding.mjs';
13
13
  import { enrichWithStoredCredentials } from './credential-overrides.mjs';
14
+ import { redactText } from './redact.mjs';
14
15
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
15
16
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
16
17
  const runtimeEnv = withBrandedEnvironment(process.env);
@@ -47,10 +48,6 @@ function doctorRow(name, stage, status, detail) {
47
48
  return { name, stage, status, ...(detail ? { detail } : {}) };
48
49
  }
49
50
 
50
- function redactText(value) {
51
- return String(value).replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]');
52
- }
53
-
54
51
  async function runDoctor(destinations) {
55
52
  const checks = [];
56
53
  if (!destinations.length) {
@@ -111,7 +108,7 @@ async function runDoctor(destinations) {
111
108
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
112
109
  if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
113
110
  const tools = listed.result?.tools || [];
114
- const toolPrefix = `${TOOL_NAME_PREFIX}${slugifyDestination(destination.name)}_`;
111
+ const toolPrefix = `${slugifyDestination(destination.name)}_`;
115
112
  const localSegments = new Set(['lint_abap', 'get_application_log', 'prepare_abap_change_set', 'apply_abap_change_set', 'check_transport_readiness', 'plan_abap_cloud_migration', 'generate_rap_regression_suite', 'run_rap_regression_suite']);
116
113
  const upstreamCount = tools.filter(tool => tool.name.startsWith(toolPrefix) && !localSegments.has(tool.name.slice(toolPrefix.length))).length;
117
114
  checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} VSP tools and ${tools.length} total MCP tools returned; chat-picker binding is host-managed`));
@@ -167,7 +164,8 @@ async function runOfflineDemo() {
167
164
  childArgs: () => [demoPath],
168
165
  destinations: [{ name: 'demo', url: 'http://demo.dest', client: '001', demo: true }],
169
166
  env: { ...runtimeEnv, SAP_AI_DEV_TOOLKIT_DESTINATION: 'demo' },
170
- log: message => console.error(message)
167
+ log: message => console.error(message),
168
+ version: pkg.version
171
169
  });
172
170
  console.error('[sap-ai-dev-toolkit] offline demo active; all SAP changes are simulated in memory');
173
171
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
@@ -181,8 +179,7 @@ function probeDiagnostic(destination) {
181
179
  const details = [];
182
180
  if (probe.httpStatus) details.push(`HTTP ${probe.httpStatus}`);
183
181
  if (probe.error) {
184
- const error = String(probe.error)
185
- .replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
182
+ const error = redactText(probe.error)
186
183
  .replace(/\s+/g, ' ')
187
184
  .slice(0, 400);
188
185
  details.push(error);
@@ -262,7 +259,7 @@ async function main() {
262
259
  let proxy;
263
260
  try {
264
261
  const binary = await binaryOrError();
265
- proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine });
262
+ proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine, version: pkg.version });
266
263
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
267
264
  process.once('SIGINT', () => shutdown('SIGINT'));
268
265
  process.once('SIGTERM', () => shutdown('SIGTERM'));
@@ -301,7 +298,7 @@ async function main() {
301
298
  else if (destination.credentials?.host) logLine(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
302
299
  }
303
300
  logLine(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
304
- const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine });
301
+ const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine, version: pkg.version });
305
302
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
306
303
  process.once('SIGINT', () => shutdown('SIGINT'));
307
304
  process.once('SIGTERM', () => shutdown('SIGTERM'));
@@ -3,11 +3,27 @@ import { constants } from 'node:fs';
3
3
  import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { basename, delimiter, dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
6
7
  import { brandedEnvValue } from './branding.mjs';
7
8
  import { slugifyDestination } from './bas-discovery.mjs';
8
9
 
9
10
  const LEGACY_MCP_SERVER_PREFIXES = ['sapAiDev_', 'basVspMcp_'];
10
11
  const COMPANION_SERVER_KIND = 'sap-development-companion';
12
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
13
+
14
+ // The self-package companion (HANA inspector) must launch the exact version
15
+ // the user installed; an unpinned `npx sap-ai-dev-toolkit` would re-resolve
16
+ // executable code from the registry at every MCP host start.
17
+ let selfPackageVersion;
18
+ async function runningPackageVersion() {
19
+ if (selfPackageVersion !== undefined) return selfPackageVersion;
20
+ try {
21
+ selfPackageVersion = String(JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version || '');
22
+ } catch {
23
+ selfPackageVersion = '';
24
+ }
25
+ return selfPackageVersion;
26
+ }
11
27
 
12
28
  export const SAP_DEVELOPMENT_MCP_SERVERS = Object.freeze([
13
29
  Object.freeze({
@@ -294,9 +310,11 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
294
310
  const env = options.env || process.env;
295
311
  const path = options.path || await resolveMcpConfigPath(env);
296
312
  const config = await readConfig(path);
313
+ const selfVersion = await runningPackageVersion();
314
+ const companionVersions = selfVersion ? { 'sap-ai-dev-toolkit': selfVersion, 'hana-cloud-inspector': selfVersion } : {};
297
315
  const generated = {
298
316
  ...buildMcpEntries(destinations, env),
299
- ...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [])
317
+ ...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [], { packageVersions: companionVersions })
300
318
  };
301
319
  const launcherCommand = options.command || (destinations.length ? await resolveMcpServerCommand(env) : null);
302
320
  if (launcherCommand) {
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,7 +609,7 @@ 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 = [];
@@ -524,7 +625,7 @@ export class MCPProxy {
524
625
  };
525
626
  for (const entry of this.children) {
526
627
  const slug = slugifyDestination(entry.destination.name, usedSlugs);
527
- const publicName = name => `${TOOL_NAME_PREFIX}${slug}_${publicToolSegment(name)}`;
628
+ const publicName = name => `${slug}_${publicToolSegment(name)}`;
528
629
  const lintName = publicName(ABAP_LINT_TOOL.name);
529
630
  publish(lintName, { handler: runABAPLint }, { ...ABAP_LINT_TOOL, name: lintName });
530
631
  try {
@@ -544,7 +645,7 @@ export class MCPProxy {
544
645
  });
545
646
  }
546
647
 
547
- if (!exposeVspTool(tool)) continue;
648
+ if (!exposeVspTool(tool, this.readOnly)) continue;
548
649
  const name = publicName(tool.name);
549
650
  publish(name, { entry, upstream: tool.name }, { ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
550
651
  }
@@ -557,6 +658,7 @@ export class MCPProxy {
557
658
  });
558
659
  }
559
660
  } catch (error) {
661
+ buildState.complete = false;
560
662
  this.eventSink(`[${entry.destination.name}] tools/list failed: ${redactText(error.message)}`, 'error');
561
663
  }
562
664
  }
@@ -578,7 +680,7 @@ export class MCPProxy {
578
680
  if (message.method === 'initialize') {
579
681
  await this.initializeChildren(message.params || {});
580
682
  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' } });
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 } });
582
684
  }
583
685
  if (!this.initialized) return rpcError(message.id, -32002, 'MCP proxy is not initialized');
584
686
  if (message.method === 'logging/setLevel' && MCP_LOG_LEVELS.includes(message.params?.level)) {
@@ -592,6 +694,10 @@ export class MCPProxy {
592
694
  }
593
695
  if (message.method === 'tools/call') {
594
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(() => {});
595
701
  const mapped = this.namespace.get(name);
596
702
  if (!mapped) return rpcError(message.id, -32602, `Unknown namespaced tool: ${name}`);
597
703
  if (mapped.handler) return rpcResult(message.id, await mapped.handler(message.params?.arguments));
@@ -615,8 +721,15 @@ export class MCPProxy {
615
721
  this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`, 'error');
616
722
  // Self-healing: a dead child (crash, OOM, transient pipe break) is
617
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.
618
727
  const childBroken = mapped.entry.child.exited || !mapped.entry.child.process.stdin.writable;
619
- 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) {
620
733
  try {
621
734
  await this.restartChild(mapped.entry);
622
735
  this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
@@ -645,33 +758,74 @@ export class MCPProxy {
645
758
  this.output = output;
646
759
  this.start();
647
760
  let buffer = '';
648
- 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 => {
649
791
  buffer += chunk;
650
792
  let newline;
651
793
  while ((newline = buffer.indexOf('\n')) >= 0) {
652
794
  const line = buffer.slice(0, newline).trim();
653
795
  buffer = buffer.slice(newline + 1);
654
796
  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'); }
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
+ }
667
814
  }
668
815
  };
669
816
  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();
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
+ }
675
829
  }
676
830
 
677
831
  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
+ }