sap-ai-dev-toolkit 0.3.1 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp-proxy.mjs CHANGED
@@ -4,6 +4,7 @@ import { sanitizeChildEnv, slugifyDestination } from './bas-discovery.mjs';
4
4
  import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
5
5
  import { createEngineeringTools } from './engineering-tools.mjs';
6
6
  import { brandedEnvValue } from './branding.mjs';
7
+ import { createBasDestinationRelay } from './bas-destination-relay.mjs';
7
8
 
8
9
  const JSONRPC = '2.0';
9
10
  const FORWARDED_METHODS = new Set(['ping', 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'completion/complete', 'logging/setLevel']);
@@ -58,8 +59,27 @@ function closeDestinationRoute(destination) {
58
59
  return closedRoutes.get(destination);
59
60
  }
60
61
 
62
+ function useBasDestinationRelay(destination, env = process.env) {
63
+ if (String(brandedEnvValue(env, 'DISABLE_BAS_RELAY') || '').toLowerCase() === 'true') return false;
64
+ if (destination.source === 'cloud-foundry') return false;
65
+ if (!destination.authentication) return false;
66
+ return Boolean(destination.url && String(destination.url).includes('.dest'));
67
+ }
68
+
61
69
  function childEnvironment(destination, env) {
62
- return { ...sanitizeChildEnv(env), ...(destination.childEnv || {}) };
70
+ const childEnv = { ...sanitizeChildEnv(env), ...(destination.childEnv || {}) };
71
+ // The VSP child must reach the local BAS relay directly, never through the
72
+ // BAS .dest proxy. Without a loopback exception every child request would
73
+ // be double-proxied and the relay's session pairing could never apply.
74
+ if (destination.relay) {
75
+ const loopback = '127.0.0.1,localhost';
76
+ for (const key of ['NO_PROXY', 'no_proxy']) {
77
+ const entries = String(childEnv[key] || '').split(',').map(value => value.trim()).filter(Boolean);
78
+ for (const host of loopback.split(',')) if (!entries.some(entry => entry.toLowerCase() === host)) entries.push(host);
79
+ childEnv[key] = entries.join(',');
80
+ }
81
+ }
82
+ return childEnv;
63
83
  }
64
84
 
65
85
 
@@ -95,12 +115,14 @@ class Child {
95
115
  this.process.on('error', error => {
96
116
  if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
97
117
  this.fail(error);
98
- void closeDestinationRoute(destination);
118
+ // Relay routes survive child crashes so self-healing restarts can reuse
119
+ // them; they are closed by the proxy shutdown path instead.
120
+ if (!destination.relay) void closeDestinationRoute(destination);
99
121
  });
100
122
  this.process.on('exit', (code, signal) => {
101
123
  if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child exited (${code ?? signal})`);
102
124
  this.fail(new Error(`child exited (${code ?? signal})`));
103
- void closeDestinationRoute(destination);
125
+ if (!destination.relay) void closeDestinationRoute(destination);
104
126
  });
105
127
  }
106
128
 
@@ -209,36 +231,87 @@ export class MCPProxy {
209
231
  start() {
210
232
  if (this.started) return this;
211
233
  this.started = true;
212
- for (const destination of this.destinations) {
213
- try {
214
- this.log(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
215
- this.children.push({
216
- destination,
217
- child: new Child(this.binary, destination, {
218
- env: this.env,
219
- spawn: this.spawn,
220
- args: this.childArgs?.(destination),
221
- log: this.log,
222
- onNotification: notification => {
223
- if (this.clientInitialized) this.output(JSON.stringify(notification));
224
- else this.pendingNotifications.push(notification);
225
- }
226
- })
227
- });
228
- } catch (error) {
229
- this.log(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`);
230
- void closeDestinationRoute(destination);
234
+ this.starting = (async () => {
235
+ for (const originalDestination of this.destinations) {
236
+ let destination = originalDestination;
237
+ try {
238
+ if (useBasDestinationRelay(originalDestination, this.env)) {
239
+ const relay = createBasDestinationRelay(originalDestination, { env: this.env, log: this.log });
240
+ const originalClose = originalDestination.close;
241
+ const relayUrl = await relay.ready;
242
+ destination = {
243
+ ...originalDestination,
244
+ url: relayUrl,
245
+ relay: true,
246
+ async close() {
247
+ await originalClose?.();
248
+ await relay.close();
249
+ }
250
+ };
251
+ this.log(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
252
+ }
253
+ this.log(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
254
+ this.children.push({
255
+ destination,
256
+ child: new Child(this.binary, destination, {
257
+ env: this.env,
258
+ spawn: this.spawn,
259
+ args: this.childArgs?.(destination),
260
+ log: this.log,
261
+ onNotification: notification => {
262
+ if (this.clientInitialized) this.output(JSON.stringify(notification));
263
+ else this.pendingNotifications.push(notification);
264
+ }
265
+ })
266
+ });
267
+ } catch (error) {
268
+ this.log(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`);
269
+ void closeDestinationRoute(destination);
270
+ }
231
271
  }
232
- }
233
- if (!this.children.length) {
234
- this.started = false;
235
- for (const destination of this.destinations) void closeDestinationRoute(destination);
236
- throw new Error('No destination child could be started');
237
- }
272
+ if (!this.children.length) {
273
+ this.started = false;
274
+ for (const destination of this.destinations) void closeDestinationRoute(destination);
275
+ throw new Error('No destination child could be started');
276
+ }
277
+ return this;
278
+ })();
279
+ // Mark the startup promise as handled here; initializeChildren re-awaits
280
+ // it so startup failures still surface as MCP initialization errors
281
+ // without triggering an unhandled rejection in between.
282
+ this.starting.catch(() => {});
238
283
  return this;
239
284
  }
240
285
 
286
+ // Self-healing mode 3: transparently restart a crashed VSP child so an
287
+ // MCP tools/call does not permanently fail after a single crash. The
288
+ // restarted child re-initializes and re-registers tools before the call
289
+ // is retried once.
290
+ async restartChild(entry) {
291
+ if (this.shuttingDown) throw new Error('MCP proxy is shutting down');
292
+ const name = entry.destination.name;
293
+ this.log(`[${name}] VSP child crashed; self-healing restart in progress`);
294
+ await entry.child.close().catch(() => {});
295
+ const child = new Child(this.binary, entry.destination, {
296
+ env: this.env,
297
+ spawn: this.spawn,
298
+ args: this.childArgs?.(entry.destination),
299
+ log: this.log,
300
+ onNotification: notification => {
301
+ if (this.clientInitialized) this.output(JSON.stringify(notification));
302
+ else this.pendingNotifications.push(notification);
303
+ }
304
+ });
305
+ entry.child = child;
306
+ entry.server = await child.initialize(this.lastInitializeParams || {});
307
+ await child.listTools().catch(() => {});
308
+ this.log(`[${name}] VSP child self-healing restart complete`);
309
+ return entry;
310
+ }
311
+
241
312
  async initializeChildren(params) {
313
+ await this.starting;
314
+ this.lastInitializeParams = params;
242
315
  const healthy = [];
243
316
  for (const entry of this.children) {
244
317
  this.log(`[${entry.destination.name}] initializing VSP MCP session`);
@@ -342,6 +415,19 @@ export class MCPProxy {
342
415
  return response.error ? { ...response, id: message.id } : rpcResult(message.id, response.result);
343
416
  } catch (error) {
344
417
  this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`);
418
+ // Self-healing: a dead child (crash, OOM, transient pipe break) is
419
+ // restarted and the call retried once before surfacing the error.
420
+ const childBroken = mapped.entry.child.exited || !mapped.entry.child.process.stdin.writable;
421
+ if (childBroken && !this.shuttingDown) {
422
+ try {
423
+ await this.restartChild(mapped.entry);
424
+ this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
425
+ const retried = await mapped.entry.child.request('tools/call', upstreamParams);
426
+ return retried.error ? { ...retried, id: message.id } : rpcResult(message.id, retried.result);
427
+ } catch (restartError) {
428
+ this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} self-healing restart failed: ${diagnosticText(restartError.message)}`);
429
+ }
430
+ }
345
431
  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}`);
346
432
  }
347
433
  }
@@ -393,10 +479,12 @@ export class MCPProxy {
393
479
  async close() {
394
480
  if (this.shuttingDown) return;
395
481
  this.shuttingDown = true;
396
- await Promise.all(this.destinations.map(async destination => {
397
- const entry = this.children.find(candidate => candidate.destination === destination);
398
- await entry?.child.close();
399
- await closeDestinationRoute(destination);
482
+ const closed = new Set();
483
+ await Promise.all(this.children.map(async entry => {
484
+ await entry.child.close();
485
+ await closeDestinationRoute(entry.destination);
486
+ closed.add(entry.destination);
400
487
  }));
488
+ await Promise.all(this.destinations.filter(destination => !closed.has(destination)).map(destination => closeDestinationRoute(destination)));
401
489
  }
402
490
  }
package/src/setup.mjs CHANGED
@@ -1,9 +1,12 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { stdin, stdout } from 'node:process';
3
- import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
3
+ import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
4
4
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
5
5
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys } from './cf-destination.mjs';
6
6
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
7
+ import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
8
+ import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
9
+ import { createBasDestinationRelay } from './bas-destination-relay.mjs';
7
10
 
8
11
  const SETUP_COMMAND = 'sap-ai-dev --setup';
9
12
 
@@ -23,6 +26,90 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
23
26
  }
24
27
  }
25
28
 
29
+ async function probeBasOnPremCredentialRoute({ destination, user, password, env }) {
30
+ const relay = createBasDestinationRelay({
31
+ ...destination,
32
+ credentials: { user, password, mode: 'bas-tunnel' }
33
+ }, { env, log: () => {} });
34
+ try {
35
+ await relay.ready;
36
+ return await relay.probeCsrfSession();
37
+ } finally {
38
+ await relay.close();
39
+ }
40
+ }
41
+
42
+ async function promptForDestinationCredentials({ destinations, credentialsPath, existing = {}, input = stdin, output = stdout, env = process.env, verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute } = {}) {
43
+ const eligible = destinations.filter(canPromptForCredentials);
44
+ const retained = new Set();
45
+ if (!eligible.length) return retained;
46
+
47
+ print(output, '');
48
+ print(output, formatStatus('Credential overrides are optional and apply only to selected BasicAuthentication destinations.', 'step', output, 'Credentials'));
49
+ print(output, ` Overrides are stored beside your MCP config in ${credentialsPath} with owner-only permissions (never inside mcp.json).`);
50
+ print(output, ' For OnPremise destinations, BAS/Cloud Foundry proxy credentials remain separate from the SAP backend credentials.');
51
+ print(output, '');
52
+
53
+ for (const destination of eligible) {
54
+ const key = credentialKeyForDestination(destination);
55
+ if (!key) continue;
56
+ const mode = credentialModeForDestination(destination);
57
+ const known = existing[key];
58
+ const routeLabel = `${destination.source === 'cloud-foundry' ? 'Cloud Foundry' : 'BAS'} ${destination.proxyType} — ${destination.name}`;
59
+ const answer = await textPrompt({
60
+ message: colorText(`🔐 ${routeLabel} — override SAP credentials? (y/n)`, 'cyan', output),
61
+ required: true,
62
+ validate: value => /^(?:y|yes|n|no)$/i.test(value.trim()) || 'Enter y or n.'
63
+ }, { input, output });
64
+ if (/^n/i.test(answer.trim())) {
65
+ await removeDestinationCredentials(credentialsPath, [key]);
66
+ print(output, formatStatus(`Credential override disabled for ${destination.name}.`, 'info', output, 'Credentials'));
67
+ continue;
68
+ }
69
+
70
+ if (known?.user && known?.password) {
71
+ print(output, formatStatus(`Credentials for ${destination.name} are already stored. Press Enter at both prompts to keep them.`, 'info', output, 'Credentials'));
72
+ }
73
+ const userAnswer = await textPrompt({
74
+ message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
75
+ required: false,
76
+ placeholder: known ? 'Enter keeps the stored user' : 'SAP user name'
77
+ }, { input, output });
78
+ const user = userAnswer.trim() || known?.user || '';
79
+ const passwordAnswer = await textPrompt({
80
+ message: colorText(`🔒 ${routeLabel} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
81
+ secret: true,
82
+ required: false
83
+ }, { input, output });
84
+ const password = passwordAnswer || known?.password || '';
85
+ if (!user || !password) {
86
+ await removeDestinationCredentials(credentialsPath, [key]);
87
+ print(output, formatStatus(`No complete credentials for ${destination.name}; no override was stored.`, 'warning', output, 'Credentials'));
88
+ continue;
89
+ }
90
+
91
+ if (mode === 'bas-tunnel') {
92
+ let route;
93
+ try {
94
+ route = await verifyOnPremCredentialRoute({ destination, user, password, env });
95
+ } catch {
96
+ route = null;
97
+ }
98
+ if (!(route?.httpStatus >= 200 && route.httpStatus < 300) || !route.tokenReceived || !(route.cookieCount > 0)) {
99
+ await removeDestinationCredentials(credentialsPath, [key]);
100
+ print(output, formatStatus(`No override stored for ${destination.name}: the BAS/Cloud Connector route did not return both a CSRF token and session cookie.`, 'warning', output, 'Credentials'));
101
+ continue;
102
+ }
103
+ }
104
+
105
+ const host = mode === 'direct' ? (destination.backendUrl || known?.host || '') : '';
106
+ await storeDestinationCredentials(credentialsPath, key, { host, user, password, mode });
107
+ retained.add(key);
108
+ print(output, formatStatus(`Stored credential override for ${destination.name}.`, 'success', output, 'Credentials'));
109
+ }
110
+ return retained;
111
+ }
112
+
26
113
  function safeProbe(probe) {
27
114
  const result = {};
28
115
  if (typeof probe?.status === 'string') result.status = probe.status;
@@ -39,6 +126,8 @@ function safeBasDestination(destination) {
39
126
  url: destination.url,
40
127
  client: destination.client,
41
128
  authentication: destination.authentication,
129
+ proxyType: destination.proxyType,
130
+ backendUrl: destination.backendUrl || null,
42
131
  probe: safeProbe(destination.probe)
43
132
  };
44
133
  }
@@ -84,6 +173,7 @@ function printConnectionInstructions(output, result) {
84
173
  print(output, ` • ${name} — ${detail}`);
85
174
  }
86
175
  print(output, formatStatus('Open the Command Palette → “MCP: List Servers”, select a generated server, and choose “Start Server”.', 'step', output, 'Next'));
176
+ print(output, formatStatus('In GitHub Copilot Chat, select an agent, open the Chat tools picker, and enable each selected destination server for this chat.', 'step', output, 'Copilot Chat'));
87
177
  print(output, formatStatus('Inspect or edit entries with “MCP: Open User Configuration”.', 'info', output, 'Config'));
88
178
  }
89
179
 
@@ -96,6 +186,7 @@ export async function runSetup({
96
186
  discoverCf = discoverCloudFoundryDestinations,
97
187
  confirmCfImport = confirmCloudFoundryImport,
98
188
  install = installMcpConfig,
189
+ verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute,
99
190
  includeSapDevelopmentToolsPrompt = false
100
191
  } = {}) {
101
192
  if (!env.H2O_URL) {
@@ -117,6 +208,7 @@ export async function runSetup({
117
208
  }
118
209
  const destinations = basDestinations.map(safeBasDestination);
119
210
  const warnings = [];
211
+ const credentialWarnings = [];
120
212
  let createdKeys = [];
121
213
  let configPath;
122
214
  let managedKeys = [];
@@ -228,6 +320,26 @@ export async function runSetup({
228
320
  shortcuts: { all: 'a' }
229
321
  }, { input, output });
230
322
  }
323
+
324
+ // Ask for an explicit credential override choice for each selected supported destination.
325
+ const credentialsPath = await resolveCredentialsPath(env, configPath);
326
+ try {
327
+ const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
328
+ const retained = await promptForDestinationCredentials({
329
+ destinations: selected,
330
+ credentialsPath,
331
+ existing: existingMap,
332
+ input,
333
+ output,
334
+ env,
335
+ verifyOnPremCredentialRoute
336
+ });
337
+ const stale = Object.keys(existingMap).filter(name => !retained.has(name));
338
+ if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
339
+ } catch (error) {
340
+ if (/Prompt interrupted/.test(error.message)) throw error;
341
+ credentialWarnings.push(`Credential overrides could not be stored: ${error.message}`);
342
+ }
231
343
  try {
232
344
  const result = await install(selected, { env, sapDevelopmentServers });
233
345
  const location = result?.path ? ` in ${result.path}` : '';
@@ -248,8 +360,9 @@ export async function runSetup({
248
360
  }
249
361
  }
250
362
  await reportWarnings(warnings);
363
+ await reportWarnings(credentialWarnings);
251
364
  await reportWarnings(cleanupWarnings);
252
- return { selected, ...(result || {}), warnings: [...warnings, ...cleanupWarnings] };
365
+ return { selected, ...(result || {}), warnings: [...warnings, ...credentialWarnings, ...cleanupWarnings] };
253
366
  } catch (error) {
254
367
  const cleanupWarnings = [];
255
368
  if (createdKeys.length) {
@@ -210,3 +210,87 @@ export async function checkboxPrompt({ message, choices, required = false, short
210
210
  }
211
211
  });
212
212
  }
213
+
214
+ // Single-line text/secret prompt matching the checkbox prompt's interaction
215
+ // style. Secrets are masked with ●, support backspace, and can be re-prompted
216
+ // through `validate`. Ctrl+C rejects with 'Prompt interrupted'.
217
+ export async function textPrompt({ message, secret = false, required = true, placeholder = '', validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
218
+ let value = '';
219
+ let renderedLines = 0;
220
+ let done = false;
221
+ let previousRawMode;
222
+
223
+ const write = text => output.write(text);
224
+ const masked = () => '●'.repeat(value.length);
225
+ const clearRendered = () => {
226
+ if (!renderedLines) return;
227
+ write(`\u001b[${renderedLines}A\u001b[J`);
228
+ renderedLines = 0;
229
+ };
230
+ const render = (error = '') => {
231
+ const columns = Number.isFinite(output?.columns) ? output.columns : 80;
232
+ const lines = [];
233
+ const hint = secret ? ' (input is hidden)' : '';
234
+ lines.push(truncateToColumns(`${message}${hint}`, columns));
235
+ const shown = secret ? masked() : value;
236
+ const display = shown || colorText(placeholder, 'cyan', output);
237
+ lines.push(truncateToColumns(`❯ ${display}`, columns));
238
+ if (error) lines.push(colorText(` ${error}`, 'red', output));
239
+ clearRendered();
240
+ write(`${lines.join('\n')}\n`);
241
+ renderedLines = lines.length;
242
+ };
243
+
244
+ return new Promise((resolve, reject) => {
245
+ const cleanup = () => {
246
+ input.off('keypress', onKeypress);
247
+ if (typeof input.setRawMode === 'function' && previousRawMode !== undefined) input.setRawMode(previousRawMode);
248
+ if (typeof input.pause === 'function') input.pause();
249
+ write('\u001b[?25h');
250
+ };
251
+ const finish = result => {
252
+ if (done) return;
253
+ done = true;
254
+ clearRendered();
255
+ const summary = secret ? '✓ saved' : (result || '(empty)');
256
+ write(`${stripAnsi(message)} ${summary}\n`);
257
+ cleanup();
258
+ resolve(result);
259
+ };
260
+ const fail = error => {
261
+ if (done) return;
262
+ done = true;
263
+ cleanup();
264
+ reject(error);
265
+ };
266
+ const onKeypress = (chunk, key = {}) => {
267
+ if (key.ctrl && key.name === 'c') return fail(new Error('Prompt interrupted'));
268
+ if (key.name === 'return' || key.name === 'enter') {
269
+ const validation = typeof validate === 'function' ? validate(value) : true;
270
+ if (validation !== true) return render(String(validation));
271
+ if (required && !value.length) return render('Enter a value (or press Ctrl+C to skip).');
272
+ return finish(value);
273
+ }
274
+ if (key.name === 'backspace' || key.name === 'delete') {
275
+ value = value.slice(0, -1);
276
+ } else if (key.sequence && !key.ctrl && !key.meta && /^.$/u.test(key.sequence)) {
277
+ value += key.sequence;
278
+ } else {
279
+ return;
280
+ }
281
+ render();
282
+ };
283
+
284
+ try {
285
+ readline.emitKeypressEvents(input);
286
+ previousRawMode = input.isRaw;
287
+ if (typeof input.setRawMode === 'function') input.setRawMode(true);
288
+ if (typeof input.resume === 'function') input.resume();
289
+ write('\u001b[?25l');
290
+ input.on('keypress', onKeypress);
291
+ render();
292
+ } catch (error) {
293
+ fail(error);
294
+ }
295
+ });
296
+ }