sap-ai-dev-toolkit 0.9.97 → 0.9.9904

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 (40) hide show
  1. package/.github/agents/abap-developer.agent.md +4 -2
  2. package/.github/agents/abap-runtime-debugger.agent.md +3 -1
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +3 -2
  4. package/.github/agents/rap-service-developer.agent.md +6 -4
  5. package/.github/agents/sap-solution-architect.agent.md +5 -3
  6. package/.github/skills/abap-debugging/SKILL.md +1 -1
  7. package/.github/skills/abap-development/SKILL.md +7 -1
  8. package/.github/skills/abap-runtime-analysis/SKILL.md +1 -1
  9. package/.github/skills/abap-testing-quality/SKILL.md +4 -2
  10. package/.github/skills/cds-development/SKILL.md +5 -1
  11. package/.github/skills/clean-core-extensibility/SKILL.md +3 -1
  12. package/.github/skills/hana-cloud-native-development/SKILL.md +1 -1
  13. package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
  14. package/.github/skills/rap-development/SKILL.md +6 -2
  15. package/.github/skills/rap-service-delivery/SKILL.md +3 -3
  16. package/.github/skills/sap-sdlc-orchestration/SKILL.md +2 -2
  17. package/.github/skills/sap-standard-api-analysis/SKILL.md +2 -0
  18. package/.github/skills/sap-transport-release/SKILL.md +1 -1
  19. package/README.md +62 -73
  20. package/dist/checksums.txt +5 -5
  21. package/dist/vsp-darwin-arm64 +0 -0
  22. package/dist/vsp-darwin-x64 +0 -0
  23. package/dist/vsp-linux-arm64 +0 -0
  24. package/dist/vsp-linux-x64 +0 -0
  25. package/dist/vsp-win32-x64.exe +0 -0
  26. package/inventory.md +25 -15
  27. package/package.json +1 -1
  28. package/patches/vsp-bas-proxy-auth.patch +342 -0
  29. package/scripts/build-vsp.mjs +2 -2
  30. package/scripts/postinstall.mjs +22 -11
  31. package/scripts/publish-npm.mjs +4 -4
  32. package/src/binary.mjs +14 -14
  33. package/src/launcher.mjs +19 -19
  34. package/src/local-sap-gui.mjs +46 -21
  35. package/src/mcp-config.mjs +35 -14
  36. package/src/mcp-proxy.mjs +26 -16
  37. package/src/setup.mjs +4 -1
  38. package/src/sso-browsers.mjs +1 -1
  39. package/src/terminal-ui.mjs +5 -3
  40. package/tools.md +15 -9
package/src/launcher.mjs CHANGED
@@ -81,15 +81,15 @@ async function runDoctor(destinations) {
81
81
  checks.push(doctorRow('-', 'MCP config repair', 'failed', redactText(error.message || error).slice(0, 300)));
82
82
  }
83
83
  for (const destination of destinations) {
84
- stopProgress.update(`Checking ${destination.name}: ADT probe, VSP startup, tools/list`);
84
+ stopProgress.update(`Checking ${destination.name}: ADT probe, runtime startup, tools/list`);
85
85
  const probe = destination.probe;
86
86
  const probeStatus = !probe || probe.status === 'skipped' ? 'skipped' : (probe.available === true ? 'passed' : 'failed');
87
87
  checks.push(doctorRow(destination.name, 'ADT probe', probeStatus, probe?.status || 'No BAS ADT probe was supplied; GetSystemInfo will check the route.'));
88
- checks.push(doctorRow(destination.name, 'VSP binary', binary ? 'passed' : 'failed', binary ? 'installed' : binaryError));
88
+ checks.push(doctorRow(destination.name, 'Runtime executable', binary ? 'passed' : 'failed', binary ? 'installed' : binaryError));
89
89
  if (!binary) {
90
- checks.push(doctorRow(destination.name, 'VSP MCP startup', 'skipped', 'The VSP binary is unavailable.'));
91
- checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'VSP MCP startup could not run.'));
92
- checks.push(doctorRow(destination.name, 'MCP tools/list', 'skipped', 'VSP MCP startup could not run.'));
90
+ checks.push(doctorRow(destination.name, 'MCP startup', 'skipped', 'The runtime executable is unavailable.'));
91
+ checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'MCP startup could not run.'));
92
+ checks.push(doctorRow(destination.name, 'MCP tools/list', 'skipped', 'MCP startup could not run.'));
93
93
  try { await destination.close?.(); } catch {}
94
94
  continue;
95
95
  }
@@ -98,7 +98,7 @@ async function runDoctor(destinations) {
98
98
  proxy.start();
99
99
  const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
100
100
  if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
101
- checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
101
+ checks.push(doctorRow(destination.name, 'MCP startup', 'passed', 'initialized'));
102
102
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
103
103
  if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
104
104
  const tools = listed.result?.tools || [];
@@ -109,13 +109,13 @@ async function runDoctor(destinations) {
109
109
  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']);
110
110
  const isUpstreamTool = tool => (tool.name.startsWith(toolPrefix) ? tool.name.slice(toolPrefix.length) : tool.name);
111
111
  const upstreamCount = tools.filter(tool => !localSegments.has(isUpstreamTool(tool))).length;
112
- 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`));
112
+ checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} upstream tools and ${tools.length} total MCP tools returned; chat-picker binding is host-managed`));
113
113
  const systemInfo = tools.find(tool => tool.name === 'get_system_info' || tool.name === `${toolPrefix}get_system_info`);
114
114
  const needsAdtUrl = destination.probe?.status === 'needs-adt-url';
115
115
  if (needsAdtUrl) {
116
116
  checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'No ADT URL is configured for this SAP GUI system; run sap-ai-dev --setup to add one.'));
117
117
  } else if (!systemInfo) {
118
- checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
118
+ checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this runtime mode.'));
119
119
  } else {
120
120
  const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
121
121
  const failed = inspected?.error || inspected?.result?.isError;
@@ -123,8 +123,8 @@ async function runDoctor(destinations) {
123
123
  }
124
124
  } catch (error) {
125
125
  const detail = redactText(error.message || error).slice(0, 300);
126
- const initialized = checks.some(row => row.name === destination.name && row.stage === 'VSP MCP startup' && row.status === 'passed');
127
- if (!initialized) checks.push(doctorRow(destination.name, 'VSP MCP startup', 'failed', detail));
126
+ const initialized = checks.some(row => row.name === destination.name && row.stage === 'MCP startup' && row.status === 'passed');
127
+ if (!initialized) checks.push(doctorRow(destination.name, 'MCP startup', 'failed', detail));
128
128
  else checks.push(doctorRow(destination.name, 'MCP tools/list', 'failed', detail));
129
129
  checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'An earlier diagnostic step failed.'));
130
130
  } finally {
@@ -179,20 +179,20 @@ async function resolveBinary() {
179
179
  const notes = [];
180
180
  const found = await findBinary(pkg, { env: runtimeEnv, log: note => notes.push(note) });
181
181
  if (found) return found;
182
- logLine(`[sap-ai-dev] ${notes.join('; ') || 'no VSP binary found'}; downloading a checksum-verified copy`);
182
+ logLine(`[sap-ai-dev] ${notes.join('; ') || 'no runtime executable found'}; downloading a checksum-verified copy`);
183
183
  try {
184
184
  const downloaded = await downloadBinary(pkg, { env: runtimeEnv });
185
- logLine(`[sap-ai-dev] VSP binary installed at ${downloaded}`);
185
+ logLine(`[sap-ai-dev] runtime executable installed at ${downloaded}`);
186
186
  return downloaded;
187
187
  } catch (error) {
188
188
  notes.push(`download fallback failed: ${error.message}${error.cause?.code ? ` (${error.cause.code})` : ''}`);
189
189
  }
190
190
  const npxDirectory = root.match(/^(.*[\\/]_npx[\\/][^\\/]+)[\\/]node_modules[\\/]/)?.[1];
191
191
  throw new Error([
192
- `VSP binary ${binaryTarget().asset} is unavailable for ${pkg.name}@${pkg.version}: ${notes.join('; ')}.`,
192
+ `Runtime executable ${binaryTarget().asset} is unavailable for ${pkg.name}@${pkg.version}: ${notes.join('; ')}.`,
193
193
  npxDirectory
194
- ? `The npm package ships this binary, so an interrupted install or an antivirus quarantine usually removed it: delete ${npxDirectory} and restart the MCP server so npx reinstalls the package, or set SAP_AI_DEV_TOOLKIT_BINARY to a trusted patched VSP binary.`
195
- : `Reinstall ${pkg.name} (a git checkout needs npm run build:vsp), check whether antivirus quarantined the binary, or set SAP_AI_DEV_TOOLKIT_BINARY to a trusted patched VSP binary.`
194
+ ? `The npm package ships this binary, so an interrupted install or an antivirus quarantine usually removed it: delete ${npxDirectory} and restart the MCP server so npx reinstalls the package, or set SAP_AI_DEV_TOOLKIT_BINARY to a trusted executable.`
195
+ : `Reinstall ${pkg.name} (a git checkout must build its bundled runtime executable first), check whether antivirus quarantined the executable, or set SAP_AI_DEV_TOOLKIT_BINARY to a trusted executable.`
196
196
  ].join(' '));
197
197
  }
198
198
 
@@ -322,7 +322,7 @@ async function configuredLocalSapGuiDestination(env) {
322
322
  const adt = (sso || !tlsServerNames.length) && !hasPathPrefix(configuredUrl)
323
323
  ? await resolveLocalAdtUrl(name, configuredUrl, client)
324
324
  : { url: configuredUrl };
325
- // The scan reached ADT directly, so an env proxy the browser's PAC would bypass must not reroute VSP.
325
+ // The scan reached ADT directly, so an env proxy the browser's PAC would bypass must not reroute the runtime.
326
326
  const direct = adt.verified ? noProxyEnv(env, new URL(adt.url).hostname) : {};
327
327
  const probe = adt.verified === undefined
328
328
  ? { status: 'configured', available: true }
@@ -340,7 +340,7 @@ async function configuredLocalSapGuiDestination(env) {
340
340
  systemId,
341
341
  authentication: 'SSO',
342
342
  ssoBrowsers,
343
- // SAP_SAML_AUTH survives sanitizeChildEnv and would make VSP demand SAML credentials.
343
+ // SAP_SAML_AUTH survives sanitizeChildEnv and would make the runtime demand SAML credentials.
344
344
  // A first sign-in has no session a hidden browser could renew, so its window opens at once.
345
345
  childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '', SAP_SSO_FIRST_LOGIN: env.SAP_SSO_FIRST_LOGIN || 'window', SAP_SSO_SILENT_TIMEOUT: env.SAP_SSO_SILENT_TIMEOUT || '15s', ...(ssoBrowsers.length ? { SAP_BROWSER_EXEC: ssoBrowsers[0].path } : {}), ...direct },
346
346
  probe
@@ -443,7 +443,7 @@ async function main() {
443
443
  let proxy;
444
444
  try {
445
445
  const binary = await binaryOrError();
446
- logLine(`[sap-ai-dev] VSP binary: ${binary}`);
446
+ logLine(`[sap-ai-dev] runtime executable: ${binary}`);
447
447
  proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine, version: pkg.version });
448
448
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
449
449
  process.once('SIGINT', () => shutdown('SIGINT'));
@@ -492,7 +492,7 @@ async function main() {
492
492
  }
493
493
  const binary = await binaryOrError();
494
494
  logLine(`[sap-ai-dev] sap-ai-dev-toolkit v${pkg.version} (node ${process.version}, pid ${process.pid})`);
495
- logLine(`[sap-ai-dev] VSP binary: ${binary}`);
495
+ logLine(`[sap-ai-dev] runtime executable: ${binary}`);
496
496
  logLine(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
497
497
  const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine, version: pkg.version });
498
498
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
@@ -164,6 +164,8 @@ function normalizeRecord(record, sourcePath) {
164
164
  authentication: ssoHintFrom(record) ? 'Basic/SSO hint' : 'Basic',
165
165
  authMode: 'basic',
166
166
  ssoHint: ssoHintFrom(record),
167
+ ...(record.uuid ? { serviceId: text(record.uuid).toLowerCase() } : {}),
168
+ ...(record.msid ? { logonGroup: text(record.logongroup || record.server || record.group) } : {}),
167
169
  proxyType: 'Internet',
168
170
  landscapePath: sourcePath,
169
171
  probe: { status: 'needs-adt-url' }
@@ -175,30 +177,53 @@ function parseLandscapeXml(content, sourcePath) {
175
177
  // Comments must not turn deleted entries into selectable systems.
176
178
  content = content.replace(/<!--[\s\S]*?-->/g, '');
177
179
  const messageServers = new Map();
178
- for (const match of content.matchAll(/<Messageserver\b([^>]*)>/gi)) {
180
+ for (const match of content.matchAll(/<Messageserver\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi)) {
179
181
  const record = attributes(match[1]);
180
- if (record.uuid) messageServers.set(record.uuid, record);
182
+ if (record.uuid) messageServers.set(record.uuid.toLowerCase(), record);
181
183
  }
182
- for (const match of content.matchAll(/<Service\b([^>]*)\/?>(?:\s*<\/Service>)?/gi)) {
184
+ const services = new Map();
185
+ for (const match of content.matchAll(/<Service\b((?:[^>"']|"[^"]*"|'[^']*')*)\/?>(?:\s*<\/Service>)?/gi)) {
183
186
  const record = attributes(match[1]);
184
- if (record.msid) {
185
- const messageServer = messageServers.get(record.msid);
186
- // For load-balanced entries `server` is a logon group, not a host.
187
- if (!messageServer?.host) continue;
188
- record.server = messageServer.host;
189
- record.msport = messageServer.port;
190
- }
191
- const normalized = normalizeRecord(record, sourcePath);
187
+ if (record.uuid) services.set(record.uuid.toLowerCase(), record);
188
+ const normalized = normalizeLandscapeService(record, messageServers, sourcePath);
192
189
  if (normalized) records.push(normalized);
193
190
  }
194
- // SAP GUI for Java files may use generic Item/Connection elements.
195
- for (const match of content.matchAll(/<(?:Item|Connection)\b([^>]*)\/?>(?:\s*<\/(?:Item|Connection)>)?/gi)) {
196
- const normalized = normalizeRecord(attributes(match[1]), sourcePath);
191
+
192
+ // Workspace trees organize visibility, not service definitions. Items can
193
+ // point at shared services, so resolve those references across all groups.
194
+ const referencedServiceIds = new Set();
195
+ for (const match of content.matchAll(/<(?:Item|Reference)\b((?:[^>"']|"[^"]*"|'[^']*')*)\/?>(?:\s*<\/(?:Item|Reference)>)?/gi)) {
196
+ const item = attributes(match[1]);
197
+ const serviceId = text(item.serviceid || item.service_id || item.link || item.ref).replace(/^#/, '').toLowerCase();
198
+ const referenced = serviceId ? services.get(serviceId) : null;
199
+ if (serviceId && !referenced) continue;
200
+ const record = referenced ? { ...referenced, ...item } : item;
201
+ if (referenced) {
202
+ for (const key of ['server', 'host', 'applicationserver', 'messageserver', 'mshost', 'msid', 'systemid', 'sid', 'r3name', 'type', 'kind']) {
203
+ if (referenced[key] && !item[key]) record[key] = referenced[key];
204
+ }
205
+ // Emit an unaliased service only once even when multiple groups include it.
206
+ if (!(item.name || item.description || item.desc) && referencedServiceIds.has(serviceId)) continue;
207
+ referencedServiceIds.add(serviceId);
208
+ }
209
+ const normalized = normalizeLandscapeService(record, messageServers, sourcePath);
197
210
  if (normalized) records.push(normalized);
198
211
  }
199
212
  return records;
200
213
  }
201
214
 
215
+ function normalizeLandscapeService(record, messageServers, sourcePath) {
216
+ if (record.msid) {
217
+ const messageServer = messageServers.get(record.msid.toLowerCase());
218
+ // For load-balanced entries `server` is a logon group, not a host.
219
+ if (!messageServer?.host) return null;
220
+ record.logongroup = record.logongroup || record.server || record.group;
221
+ record.server = messageServer.host;
222
+ record.msport = messageServer.port;
223
+ }
224
+ return normalizeRecord(record, sourcePath);
225
+ }
226
+
202
227
  function parseSapLogonIni(content, sourcePath) {
203
228
  const sections = new Map();
204
229
  let section = '';
@@ -390,7 +415,7 @@ export async function discoverSapGuiSystems({ env = process.env, paths } = {}) {
390
415
  if (!systems.length && !paths) systems = await parseSapGuiCandidatePaths(await broadSelfHealingSapGuiCandidates(env));
391
416
  const seen = new Set();
392
417
  return systems.filter(system => {
393
- const key = `${system.name}\0${system.host}\0${system.systemId}\0${system.instance}\0${system.client}`.toLowerCase();
418
+ const key = `${system.serviceId || ''}\0${system.logonGroup || ''}\0${system.name}\0${system.host}\0${system.systemId}\0${system.instance}\0${system.client}`.toLowerCase();
394
419
  if (seen.has(key)) return false;
395
420
  seen.add(key);
396
421
  return true;
@@ -412,7 +437,7 @@ export function hasExplicitPort(url) {
412
437
  return /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*:\d+(?:[/?#]|$)/i.test(String(url || '').trim());
413
438
  }
414
439
 
415
- // VSP appends every ADT path to the base URL, so a pasted ADT path would double up.
440
+ // The runtime appends every ADT path to the base URL, so a pasted ADT path would double up.
416
441
  export function adtBaseUrl(url) {
417
442
  return String(url || '').trim().replace(/[?#].*$/, '').replace(/\/sap\/bc\/adt(?:\/.*)?$/i, '').replace(/\/+$/, '');
418
443
  }
@@ -432,7 +457,7 @@ function adtRank(finding) {
432
457
  return (finding.secure ? 0 : 2) + (finding.status >= 300 && finding.status < 400 ? 1 : 0);
433
458
  }
434
459
 
435
- // Picks the ADT URL from VSP detect's JSON, or explains why there is none.
460
+ // Picks the ADT URL from runtime detect JSON, or explains why there is none.
436
461
  export function adtVerdict(result, protocol = 'https:') {
437
462
  const findings = Array.isArray(result?.findings) ? result.findings.filter(Boolean) : [];
438
463
  const usable = findings
@@ -452,7 +477,7 @@ export function adtVerdict(result, protocol = 'https:') {
452
477
  if (inactive) return { url: '', reason: `SAP answers at ${inactive.url} but /sap/bc/adt is not active; ask basis to activate it in SICF` };
453
478
  const tls = findings.find(finding => finding.kind === 'tls-name-mismatch');
454
479
  if (tls && /certificate required|bad certificate/i.test(String(tls.detail))) {
455
- return { url: '', reason: `port ${tls.port} demands a client certificate (smart card), which VSP cannot present` };
480
+ return { url: '', reason: `port ${tls.port} demands a client certificate (smart card), which the runtime cannot present` };
456
481
  }
457
482
  if (tls) return { url: '', reason: `port ${tls.port} presents a certificate ${tls.certHost ? `for ${tls.certHost}` : 'this machine does not trust'}${tls.detail ? ` (${tls.detail})` : ''}` };
458
483
  const open = findings.filter(finding => ['open', 'http'].includes(finding.kind)).map(finding => finding.port);
@@ -460,7 +485,7 @@ export function adtVerdict(result, protocol = 'https:') {
460
485
  return { url: '', reason: `no port answered on ${host}` };
461
486
  }
462
487
 
463
- // A portless URL means 443, which on-premise ICMs rarely serve; VSP's `detect`
488
+ // A portless URL means 443, which on-premise ICMs rarely serve; runtime `detect`
464
489
  // probes the conventional ADT ports and reports which one answers. A host that
465
490
  // accepts every port makes each probe wait out its HTTP timeouts, so the full
466
491
  // sweep gets minutes.
@@ -484,10 +509,10 @@ export async function detectAdtUrl(binary, { url, client, instance, env = proces
484
509
  } catch (error) {
485
510
  const why = error.killed ? `timed out after ${Math.round(timeoutMs / 1000)} s` : (typeof error.code === 'string' ? error.code : `exit code ${error.code}`);
486
511
  const blocked = ['EACCES', 'EPERM', 'UNKNOWN'].includes(error.code) ? '; antivirus or application control may block it' : '';
487
- return { url: '', reason: `VSP detect could not run (${why}) from ${binary}${blocked}` };
512
+ return { url: '', reason: `Runtime detect could not run (${why}) from ${binary}${blocked}` };
488
513
  }
489
514
  try { return adtVerdict(JSON.parse(stdout), configured.protocol); }
490
- catch { return { url: '', reason: 'VSP detect returned unreadable output' }; }
515
+ catch { return { url: '', reason: 'Runtime detect returned unreadable output' }; }
491
516
  }
492
517
 
493
518
  // Application servers appear by their SM51 names, host_SID_NN.
@@ -166,8 +166,10 @@ function npxLaunchArgs(entry) {
166
166
  return null;
167
167
  }
168
168
 
169
- // The package version a PATH sap-ai-dev starts, found through npm's global
170
- // layouts (Unix bin symlink, Windows .cmd shim); null when the layout is unknown.
169
+ // Resolves how generated destination entries launch the toolkit: the global
170
+ // sap-ai-dev command when it is on PATH, otherwise the pinned package
171
+ // through npx (routed through cmd /c on Windows) so entries written by a
172
+ // pure `npx sap-ai-dev-toolkit --setup` run still start on every host.
171
173
  async function commandPackageVersion(command) {
172
174
  const target = await realpath(command).catch(() => command);
173
175
  for (const manifest of [join(dirname(dirname(target)), 'package.json'), join(dirname(command), 'node_modules', 'sap-ai-dev-toolkit', 'package.json')]) {
@@ -181,10 +183,6 @@ async function commandPackageVersion(command) {
181
183
  return null;
182
184
  }
183
185
 
184
- // Resolves how generated destination entries launch the toolkit: the global
185
- // sap-ai-dev command when it is on PATH and is this version, otherwise the
186
- // pinned package through npx (routed through cmd /c on Windows) so entries
187
- // written by a pure `npx sap-ai-dev-toolkit --setup` run still start on every host.
188
186
  async function resolveLauncherInstallCommand(env) {
189
187
  const resolved = await resolveMcpServerCommand(env);
190
188
  const selfVersion = await runningPackageVersion();
@@ -192,7 +190,6 @@ async function resolveLauncherInstallCommand(env) {
192
190
  if (resolved !== 'sap-ai-dev') {
193
191
  const installed = await commandPackageVersion(resolved);
194
192
  if (installed === null || !selfVersion || installed === selfVersion) return { command: resolved, args: null };
195
- // A stale global install would keep starting the old launcher and VSP binary.
196
193
  warning = `${resolved} is sap-ai-dev-toolkit ${installed}, not ${selfVersion}; MCP entries start sap-ai-dev-toolkit@${selfVersion} through npx instead. Run "npm install -g sap-ai-dev-toolkit@${selfVersion}" to use the global command.`;
197
194
  }
198
195
  return { ...npxPackageLauncher(selfVersion), warning };
@@ -343,7 +340,7 @@ function isPackageLauncher(entry) {
343
340
  && hasPackage(npxArgs, legacyPackages);
344
341
  // Current launchers are treated as managed only when tagged by this add-on.
345
342
  // Some early bas-mcp-addon entries were not tagged with BAS_EXT, though, and
346
- // leaving them behind keeps advertising the old full VSP surface (~159 tools).
343
+ // leaving them behind keeps advertising the old full child tool surface (~159 tools).
347
344
  return (entry?.BAS_EXT === 'true' && (currentCommand || currentNpxLauncher || legacyCommand || legacyNpxLauncher))
348
345
  || legacyCommand
349
346
  || legacyNpxLauncher;
@@ -591,6 +588,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
591
588
  options = destinationsOrOptions || {};
592
589
  }
593
590
  const env = options.env || process.env;
591
+ const additive = options.additive === true;
594
592
  const path = options.path || await resolveMcpConfigPath(env);
595
593
  const installWarnings = [];
596
594
  // recoverInvalid: BOM/encoding damage, VS Code JSONC comments, torn writes
@@ -626,7 +624,9 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
626
624
  }
627
625
  }
628
626
  const servers = Object.create(null);
627
+ const managedEntries = [];
629
628
  const managedInputIds = new Set();
629
+ const generatedNames = new Set(Object.keys(generated));
630
630
  for (const [name, entry] of Object.entries(config.servers)) {
631
631
  const managed = isPackageLauncher(entry)
632
632
  && typeof destinationValue(entry?.env) === 'string'
@@ -639,17 +639,38 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
639
639
  if (match) managedInputIds.add(match[1]);
640
640
  }
641
641
  }
642
- if (!LEGACY_MCP_SERVER_PREFIXES.some(prefix => name.startsWith(prefix)) && !managed && !isCompanionServer(entry)) servers[name] = entry;
642
+ const exactManagedUpdate = managed && generatedNames.has(name)
643
+ && destinationValue(entry.env) === generated[name]?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION
644
+ && destinationSource(entry.env) === generated[name]?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE;
645
+ if (additive && (managed || isCompanionServer(entry)) && !exactManagedUpdate) managedEntries.push([name, entry]);
646
+ else if (!managed && !isCompanionServer(entry) && !LEGACY_MCP_SERVER_PREFIXES.some(prefix => name.startsWith(prefix))) servers[name] = entry;
643
647
  }
648
+ const retainedNames = new Set(managedEntries.map(([name]) => name));
644
649
  for (const name of Object.keys(generated)) {
645
- if (Object.hasOwn(servers, name)) {
646
- throw new Error(`MCP server "${name}" already exists and is not managed by sap-ai-dev-toolkit`);
650
+ if (Object.hasOwn(servers, name) || retainedNames.has(name)) {
651
+ const existing = Object.hasOwn(servers, name) ? servers[name] : config.servers[name];
652
+ const conflict = Object.hasOwn(servers, name)
653
+ ? 'already exists and is not managed by sap-ai-dev-toolkit'
654
+ : `conflicts with a different managed destination${isCompanionServer(existing) ? ' or companion server' : ''}`;
655
+ throw new Error(`MCP server "${name}" ${conflict}`);
647
656
  }
648
657
  }
649
- config.servers = { ...servers, ...generated };
658
+ config.servers = { ...servers, ...Object.fromEntries(managedEntries), ...generated };
650
659
  if (generatedInputs.size || managedInputIds.size) {
651
- const existingInputs = Array.isArray(config.inputs) ? config.inputs.filter(input => !generatedInputs.has(input?.id) && !managedInputIds.has(input?.id)) : [];
652
- config.inputs = [...existingInputs, ...generatedInputs.values()];
660
+ const retainedInputIds = new Set();
661
+ if (additive) {
662
+ for (const [, entry] of managedEntries) {
663
+ for (const value of Object.values(entry.env || {})) {
664
+ const match = typeof value === 'string' ? value.match(/^\$\{input:([^}]+)}$/) : null;
665
+ if (match) retainedInputIds.add(match[1]);
666
+ }
667
+ }
668
+ }
669
+ const existingInputs = Array.isArray(config.inputs) ? config.inputs.filter(input =>
670
+ !managedInputIds.has(input?.id) || retainedInputIds.has(input?.id)) : [];
671
+ const inputsById = new Map(existingInputs.map(input => [input?.id, input]));
672
+ for (const [id, input] of generatedInputs) inputsById.set(id, input);
673
+ config.inputs = [...inputsById.values()];
653
674
  }
654
675
  await writeConfig(path, config);
655
676
  return { path, servers: generated, warnings: installWarnings };
package/src/mcp-proxy.mjs CHANGED
@@ -45,10 +45,13 @@ const APPLICATION_LOG_DESCRIPTION = 'Read SAP application log (SLG1) entries. Re
45
45
  const NON_RETRIABLE_VSP_TOOLS = new Set([
46
46
  'Activate',
47
47
  'ActivateMultiple',
48
+ 'CreateObject',
48
49
  'CreateTransport',
49
50
  'EditSource',
50
51
  'LockObject',
52
+ 'PublishServiceBinding',
51
53
  'UnlockObject',
54
+ 'UnpublishServiceBinding',
52
55
  'WriteSource'
53
56
  ]);
54
57
 
@@ -68,12 +71,13 @@ function requestTimeoutMs(env) {
68
71
  }
69
72
 
70
73
 
71
- // Keep the default public VSP surface small enough for developer-lifecycle use.
74
+ // Keep the default public tool surface small enough for developer-lifecycle use.
72
75
  // Hidden upstream tools can still be used by local workflow tools when needed.
73
76
  export const PUBLIC_VSP_TOOLS = new Set([
74
77
  'Activate',
75
78
  'ActivateMultiple',
76
79
  'CompareSource',
80
+ 'CreateObject',
77
81
  'CreateTransport',
78
82
  'DebuggerAttach',
79
83
  'DebuggerDetach',
@@ -116,13 +120,19 @@ export const PUBLIC_VSP_TOOLS = new Set([
116
120
  'ListTransports',
117
121
  'LockObject',
118
122
  'PrettyPrint',
123
+ 'PublishServiceBinding',
119
124
  'RunATCCheck',
120
125
  'RunQuery',
121
126
  'RunUnitTests',
122
127
  'SearchObject',
123
128
  'SetBreakpoint',
124
129
  'SyntaxCheck',
130
+ // UI5 write tools stay hidden: the ADT filestore is read-only.
131
+ 'UI5GetApp',
132
+ 'UI5GetFileContent',
133
+ 'UI5ListApps',
125
134
  'UnlockObject',
135
+ 'UnpublishServiceBinding',
126
136
  'WriteSource'
127
137
  ]);
128
138
 
@@ -175,7 +185,7 @@ function useProxyAuthentication(destination) {
175
185
  function childEnvironment(destination, env) {
176
186
  const childEnv = { ...sanitizeChildEnv(env), ...(destination.childEnv || {}) };
177
187
  if (useProxyAuthentication(destination)) {
178
- // VSP loads SAP_* values from local .env files. Set every supported local
188
+ // The runtime loads SAP_* values from local .env files. Set every supported local
179
189
  // auth variable explicitly empty so dotenv cannot supply a fallback
180
190
  // identity or session instead of the selected BAS destination identity.
181
191
  for (const key of [
@@ -200,7 +210,7 @@ export function childArguments(destination, env = process.env) {
200
210
 
201
211
  // Windows cannot exec a .js/.mjs file directly (no shebang support), so test
202
212
  // fixtures written in JavaScript are launched through the current Node binary.
203
- // The production VSP binary is a native executable and is unaffected; a
213
+ // The production runtime executable is native and unaffected; a
204
214
  // Windows .cmd/.bat override needs cmd /c because Node refuses to spawn
205
215
  // batch files directly (EINVAL since the CVE-2024-27980 hardening).
206
216
  function spawnCommand(binary) {
@@ -237,16 +247,16 @@ class Child {
237
247
  // Without a listener that is an uncaught exception that kills the whole
238
248
  // MCP server — exactly the crash window self-healing is meant to cover.
239
249
  this.process.stdin.on('error', error => {
240
- if (!this.closing && this.options.log) this.options.log(`[${destination.name}] VSP child stdin error: ${diagnosticText(error.message)}`);
250
+ if (!this.closing && this.options.log) this.options.log(`[${destination.name}] runtime process stdin error: ${diagnosticText(error.message)}`);
241
251
  this.fail(new Error(`child stdin failed: ${error.message}`));
242
252
  });
243
253
  this.process.on('error', error => {
244
- if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
254
+ if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] runtime process error: ${diagnosticText(error.message)}`);
245
255
  this.fail(error);
246
256
  void closeDestinationRoute(destination);
247
257
  });
248
258
  this.process.on('exit', (code, signal) => {
249
- if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child exited (${code ?? signal})`);
259
+ if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] runtime process exited (${code ?? signal})`);
250
260
  this.fail(new Error(`child exited (${code ?? signal})`));
251
261
  void closeDestinationRoute(destination);
252
262
  });
@@ -327,7 +337,7 @@ class Child {
327
337
  try { this.process.stdin.write(`${JSON.stringify({ jsonrpc: JSONRPC, method, ...(params === undefined ? {} : { params })})}\n`); } catch {}
328
338
  }
329
339
 
330
- // VSP's own "Error:" line explains an exit, and stderr can still be draining when 'exit' fires.
340
+ // The runtime's own "Error:" line explains an exit, and stderr can still be draining when 'exit' fires.
331
341
  async stopReason(waitMs = 1000) {
332
342
  let timer;
333
343
  await Promise.race([this.stderrClosed, new Promise(resolve => { timer = setTimeout(resolve, waitMs); })]);
@@ -352,7 +362,7 @@ class Child {
352
362
  return tools;
353
363
  }
354
364
 
355
- // The VSP tool surface is fixed for the process lifetime (--mode is a
365
+ // The runtime tool surface is fixed for the process lifetime (--mode is a
356
366
  // launch argument), so listings are memoized per child. The memo is
357
367
  // cleared on failure, on child exit, and on upstream list_changed.
358
368
  listToolsCached() {
@@ -465,7 +475,7 @@ export class MCPProxy {
465
475
  // keeping serverInfo's children[0] fallback and merged slug numbering deterministic.
466
476
  const started = await Promise.all(this.destinations.map(destination => (async () => {
467
477
  try {
468
- this.eventSink(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
478
+ this.eventSink(`[${destination.name}] starting runtime process (client=${destination.client || '001'})`);
469
479
  return {
470
480
  destination,
471
481
  child: new Child(this.binary, destination, this.childSpawnOptions(destination))
@@ -491,7 +501,7 @@ export class MCPProxy {
491
501
  return this;
492
502
  }
493
503
 
494
- // Self-healing mode 3: transparently restart a crashed VSP child so an
504
+ // Self-healing mode 3: transparently restart a crashed child so an
495
505
  // MCP tools/call does not permanently fail after a single crash. The
496
506
  // restarted child re-initializes and re-registers tools before the call
497
507
  // is retried once. Concurrent triggers share one restart (no orphaned
@@ -505,11 +515,11 @@ export class MCPProxy {
505
515
  const now = Date.now();
506
516
  state.timestamps = state.timestamps.filter(at => now - at < RESTART_WINDOW_MS);
507
517
  if (state.timestamps.length >= MAX_RESTARTS_PER_WINDOW) {
508
- throw new Error(`VSP child restart budget exhausted (${MAX_RESTARTS_PER_WINDOW} in ${RESTART_WINDOW_MS / 1000}s); surfacing the failure instead of respawning`);
518
+ throw new Error(`Runtime process restart budget exhausted (${MAX_RESTARTS_PER_WINDOW} in ${RESTART_WINDOW_MS / 1000}s); surfacing the failure instead of respawning`);
509
519
  }
510
520
  state.inFlight = (async () => {
511
521
  const name = entry.destination.name;
512
- this.eventSink(`[${name}] VSP child crashed; self-healing restart in progress`, 'warning');
522
+ this.eventSink(`[${name}] runtime process crashed; self-healing restart in progress`, 'warning');
513
523
  try {
514
524
  await entry.child.close().catch(() => {});
515
525
  const child = new Child(this.binary, entry.destination, this.childSpawnOptions(entry.destination));
@@ -518,7 +528,7 @@ export class MCPProxy {
518
528
  this.toolsCache = null;
519
529
  await child.listToolsCached().catch(() => {});
520
530
  state.timestamps.push(Date.now());
521
- this.eventSink(`[${name}] VSP child self-healing restart complete`);
531
+ this.eventSink(`[${name}] runtime process self-healing restart complete`);
522
532
  this.emitToolsListChanged();
523
533
  return entry;
524
534
  } finally {
@@ -544,16 +554,16 @@ export class MCPProxy {
544
554
  const initialized = await Promise.all(this.children.map(async entry => {
545
555
  const { destination } = entry;
546
556
  const startedAt = Date.now();
547
- this.eventSink(`[${destination.name}] initializing VSP MCP session`);
557
+ this.eventSink(`[${destination.name}] initializing runtime MCP session`);
548
558
  for (let attempt = 1; ; attempt += 1) {
549
559
  const attemptStartedAt = Date.now();
550
- // VSP answers initialize only after its browser sign-in, which waits on a person.
560
+ // The runtime answers initialize only after browser sign-in, which waits on a person.
551
561
  const signIn = destination.authentication === 'SSO'
552
562
  ? setInterval(() => this.eventSink(`[${destination.name}] still waiting for the SSO sign-in (${Math.round((Date.now() - attemptStartedAt) / 1000)} s): finish it in the browser window (it may be behind other windows); restarting the server cancels it`), this.ssoHeartbeatMs)
553
563
  : undefined;
554
564
  try {
555
565
  entry.server = await entry.child.initialize(params).finally(() => clearInterval(signIn));
556
- this.eventSink(`[${destination.name}] VSP MCP session initialized (${Date.now() - startedAt}ms)`);
566
+ this.eventSink(`[${destination.name}] runtime MCP session initialized (${Date.now() - startedAt}ms)`);
557
567
  return entry;
558
568
  } catch (error) {
559
569
  const reason = await entry.child.stopReason();
package/src/setup.mjs CHANGED
@@ -705,6 +705,7 @@ export async function runSetup({
705
705
  return { skipped: true, reason: 'no-destinations', warnings: [...warnings, ...cleanupWarnings] };
706
706
  };
707
707
  let selected;
708
+ let additiveSetup = false;
708
709
  if (!selectable.length) {
709
710
  // Nothing was detected — no BAS/CF destinations, or no SAP GUI entries.
710
711
  // Offer the manual MCP entry wizard before giving up.
@@ -721,9 +722,11 @@ export async function runSetup({
721
722
  }, { input, output });
722
723
  if (choice !== 'manual') return await exitWithoutDestinations();
723
724
  selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
725
+ additiveSetup = true;
724
726
  if (!selected.length) return await exitWithoutDestinations();
725
727
  } else {
726
728
  selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl, previousAdtUrls });
729
+ additiveSetup = selected.length > 0;
727
730
  }
728
731
 
729
732
  let sapDevelopmentServers = [];
@@ -746,7 +749,7 @@ export async function runSetup({
746
749
  }
747
750
 
748
751
  try {
749
- const result = await install(selected, { env, sapDevelopmentServers });
752
+ const result = await install(selected, { env, sapDevelopmentServers, additive: additiveSetup });
750
753
  const location = result?.path ? ` in ${result.path}` : '';
751
754
  print(output, '');
752
755
  const configuredCount = selected.length + sapDevelopmentServers.length;
@@ -59,7 +59,7 @@ async function fileExists(path) {
59
59
  }
60
60
  }
61
61
 
62
- // Every Chromium-based browser VSP can drive, best first; SAP_BROWSER_EXEC pins one ahead of the rest.
62
+ // Every Chromium-based browser the runtime can drive, best first; SAP_BROWSER_EXEC pins one ahead of the rest.
63
63
  export async function findSsoBrowsers({ env = process.env, platform = process.platform, exists = fileExists, appPaths = registeredAppPaths } = {}) {
64
64
  const candidates = [];
65
65
  const pinned = envValue(env, 'SAP_BROWSER_EXEC');
@@ -274,17 +274,19 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
274
274
  const render = (error = '') => {
275
275
  const columns = Number.isFinite(output?.columns) ? output.columns : 80;
276
276
  const indexes = visibleIndexes();
277
+ const fixedLines = 1 + (searchable ? 1 : 0) + (Object.hasOwn(actions, 'm') ? 1 : 0) + footer().length;
277
278
  const pageSize = searchable
278
- ? Math.max(1, checkboxPageSize(output, indexes.length + 1) - 1)
279
+ ? Math.max(1, Math.min(indexes.length, (Number.isFinite(output?.rows) ? output.rows : 24) - fixedLines - 7))
279
280
  : checkboxPageSize(output, indexes.length);
280
281
  const cursorPosition = indexes.indexOf(cursor);
281
282
  ensureVisible(pageSize, indexes.length, cursorPosition);
282
283
  const lines = [];
283
284
  lines.push(truncateToColumns(`${message} (Space: select, ${shortcuts?.all || 'a'}: toggle all, Enter: confirm)`, columns));
284
285
  if (searchable) {
285
- const hint = searchQuery ? 'Backspace edits; Esc clears' : 'type prefix; / before a or m';
286
+ const hint = searchQuery ? 'Backspace edits; Esc clears' : 'type prefix to search';
286
287
  lines.push(truncateToColumns(`Search: ${searchQuery || 'type prefix'} (${hint})`, columns));
287
288
  }
289
+ if (Object.hasOwn(actions, 'm')) lines.push(truncateToColumns('m: add system manually', columns));
288
290
  const end = Math.min(indexes.length, top + pageSize);
289
291
  for (let index = top; index < end; index += 1) {
290
292
  const choiceIndex = indexes[index];
@@ -505,7 +507,7 @@ export async function textPrompt({ message, secret = false, required = true, pla
505
507
  }
506
508
 
507
509
  // Animated progress line for long-running steps (destination discovery,
508
- // ADT probes, VSP binary provisioning). TTY outputs get spinner frames on an
510
+ // ADT probes, runtime provisioning). TTY outputs get spinner frames on an
509
511
  // unref'd timer that starts after a short delay, so steps that finish quickly
510
512
  // never flash anything. Non-TTY outputs (pipes, MCP hosts, --json callers,
511
513
  // CI) stay completely silent so machine consumers never see progress noise.