sap-ai-dev-toolkit 0.9.94 → 0.9.9903

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 (39) hide show
  1. package/.github/agents/abap-developer.agent.md +2 -2
  2. package/.github/agents/abap-runtime-debugger.agent.md +1 -1
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +3 -2
  4. package/.github/agents/rap-service-developer.agent.md +4 -4
  5. package/.github/agents/sap-solution-architect.agent.md +3 -3
  6. package/.github/skills/abap-debugging/SKILL.md +1 -1
  7. package/.github/skills/abap-development/SKILL.md +1 -1
  8. package/.github/skills/abap-runtime-analysis/SKILL.md +1 -1
  9. package/.github/skills/abap-testing-quality/SKILL.md +2 -2
  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-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 +2 -2
  15. package/.github/skills/rap-service-delivery/SKILL.md +2 -2
  16. package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
  17. package/.github/skills/sap-transport-release/SKILL.md +1 -1
  18. package/README.md +61 -70
  19. package/dist/checksums.txt +5 -5
  20. package/dist/vsp-darwin-arm64 +0 -0
  21. package/dist/vsp-darwin-x64 +0 -0
  22. package/dist/vsp-linux-arm64 +0 -0
  23. package/dist/vsp-linux-x64 +0 -0
  24. package/dist/vsp-win32-x64.exe +0 -0
  25. package/inventory.md +25 -15
  26. package/package.json +1 -1
  27. package/patches/vsp-bas-proxy-auth.patch +342 -0
  28. package/scripts/build-vsp.mjs +2 -2
  29. package/scripts/postinstall.mjs +22 -11
  30. package/scripts/publish-npm.mjs +4 -4
  31. package/src/binary.mjs +14 -14
  32. package/src/launcher.mjs +52 -37
  33. package/src/local-sap-gui.mjs +46 -21
  34. package/src/mcp-config.mjs +73 -11
  35. package/src/mcp-proxy.mjs +26 -16
  36. package/src/setup.mjs +4 -1
  37. package/src/sso-browsers.mjs +1 -1
  38. package/src/terminal-ui.mjs +5 -3
  39. package/tools.md +15 -9
package/src/launcher.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
3
4
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
5
  import { isIP } from 'node:net';
5
6
  import { dirname, join } from 'node:path';
@@ -8,7 +9,7 @@ import { discoverDestinations, remediation, slugifyDestination, statusRows } fro
8
9
  import { adtBaseUrl, adtPort, detectAdtUrl, discoverSapGuiSystems, hasExplicitPort, hasPathPrefix } from './local-sap-gui.mjs';
9
10
  import { binaryTarget, cacheDirectory, downloadBinary, findBinary } from './binary.mjs';
10
11
  import { MCPProxy } from './mcp-proxy.mjs';
11
- import { installMcpConfig, npxMcpLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
12
+ import { configuredVersionHandoff, installMcpConfig, npxPackageLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
12
13
  import { runSetup } from './setup.mjs';
13
14
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
14
15
  import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
@@ -80,15 +81,15 @@ async function runDoctor(destinations) {
80
81
  checks.push(doctorRow('-', 'MCP config repair', 'failed', redactText(error.message || error).slice(0, 300)));
81
82
  }
82
83
  for (const destination of destinations) {
83
- stopProgress.update(`Checking ${destination.name}: ADT probe, VSP startup, tools/list`);
84
+ stopProgress.update(`Checking ${destination.name}: ADT probe, runtime startup, tools/list`);
84
85
  const probe = destination.probe;
85
86
  const probeStatus = !probe || probe.status === 'skipped' ? 'skipped' : (probe.available === true ? 'passed' : 'failed');
86
87
  checks.push(doctorRow(destination.name, 'ADT probe', probeStatus, probe?.status || 'No BAS ADT probe was supplied; GetSystemInfo will check the route.'));
87
- 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));
88
89
  if (!binary) {
89
- checks.push(doctorRow(destination.name, 'VSP MCP startup', 'skipped', 'The VSP binary is unavailable.'));
90
- checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'VSP MCP startup could not run.'));
91
- 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.'));
92
93
  try { await destination.close?.(); } catch {}
93
94
  continue;
94
95
  }
@@ -97,7 +98,7 @@ async function runDoctor(destinations) {
97
98
  proxy.start();
98
99
  const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
99
100
  if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
100
- checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
101
+ checks.push(doctorRow(destination.name, 'MCP startup', 'passed', 'initialized'));
101
102
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
102
103
  if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
103
104
  const tools = listed.result?.tools || [];
@@ -108,13 +109,13 @@ async function runDoctor(destinations) {
108
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']);
109
110
  const isUpstreamTool = tool => (tool.name.startsWith(toolPrefix) ? tool.name.slice(toolPrefix.length) : tool.name);
110
111
  const upstreamCount = tools.filter(tool => !localSegments.has(isUpstreamTool(tool))).length;
111
- 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`));
112
113
  const systemInfo = tools.find(tool => tool.name === 'get_system_info' || tool.name === `${toolPrefix}get_system_info`);
113
114
  const needsAdtUrl = destination.probe?.status === 'needs-adt-url';
114
115
  if (needsAdtUrl) {
115
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.'));
116
117
  } else if (!systemInfo) {
117
- 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.'));
118
119
  } else {
119
120
  const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
120
121
  const failed = inspected?.error || inspected?.result?.isError;
@@ -122,8 +123,8 @@ async function runDoctor(destinations) {
122
123
  }
123
124
  } catch (error) {
124
125
  const detail = redactText(error.message || error).slice(0, 300);
125
- const initialized = checks.some(row => row.name === destination.name && row.stage === 'VSP MCP startup' && row.status === 'passed');
126
- 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));
127
128
  else checks.push(doctorRow(destination.name, 'MCP tools/list', 'failed', detail));
128
129
  checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'An earlier diagnostic step failed.'));
129
130
  } finally {
@@ -178,20 +179,20 @@ async function resolveBinary() {
178
179
  const notes = [];
179
180
  const found = await findBinary(pkg, { env: runtimeEnv, log: note => notes.push(note) });
180
181
  if (found) return found;
181
- 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`);
182
183
  try {
183
184
  const downloaded = await downloadBinary(pkg, { env: runtimeEnv });
184
- logLine(`[sap-ai-dev] VSP binary installed at ${downloaded}`);
185
+ logLine(`[sap-ai-dev] runtime executable installed at ${downloaded}`);
185
186
  return downloaded;
186
187
  } catch (error) {
187
188
  notes.push(`download fallback failed: ${error.message}${error.cause?.code ? ` (${error.cause.code})` : ''}`);
188
189
  }
189
190
  const npxDirectory = root.match(/^(.*[\\/]_npx[\\/][^\\/]+)[\\/]node_modules[\\/]/)?.[1];
190
191
  throw new Error([
191
- `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('; ')}.`,
192
193
  npxDirectory
193
- ? `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.`
194
- : `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.`
195
196
  ].join(' '));
196
197
  }
197
198
 
@@ -268,6 +269,33 @@ function noProxyEnv(env, host) {
268
269
  return { NO_PROXY: noProxy, no_proxy: noProxy };
269
270
  }
270
271
 
272
+ // Runs a child on this process's stdio and mirrors its exit; rejects only when it cannot start.
273
+ async function runOnOwnStdio(command, commandArgs, env) {
274
+ const child = spawn(command, commandArgs, { env, stdio: 'inherit' });
275
+ process.once('SIGINT', () => child.kill('SIGINT'));
276
+ process.once('SIGTERM', () => child.kill('SIGTERM'));
277
+ const [code, signal] = await new Promise((resolve, reject) => {
278
+ child.once('error', reject);
279
+ child.once('exit', (exitCode, exitSignal) => resolve([exitCode, exitSignal]));
280
+ });
281
+ if (signal) process.kill(process.pid, signal);
282
+ process.exitCode = code ?? 1;
283
+ }
284
+
285
+ // A source checkout runs as-is: its developer chose that code.
286
+ async function handOffToConfiguredVersion() {
287
+ const handoff = existsSync(join(root, '.git')) ? null : configuredVersionHandoff(runtimeEnv, pkg.version);
288
+ if (!handoff) return false;
289
+ logLine(`[sap-ai-dev] sap-ai-dev-toolkit ${pkg.version} is older than ${handoff.version}, which configured this MCP entry; self-healing: handing off to sap-ai-dev-toolkit@${handoff.version} through npx. Run "npm install -g sap-ai-dev-toolkit@${handoff.version}" to update the global install.`);
290
+ try {
291
+ await runOnOwnStdio(handoff.command, handoff.args, { ...runtimeEnv, SAP_AI_DEV_TOOLKIT_HANDOFF: pkg.version });
292
+ return true;
293
+ } catch (error) {
294
+ logLine(`[sap-ai-dev] handoff to sap-ai-dev-toolkit@${handoff.version} could not start (${error.message}); continuing with ${pkg.version}`);
295
+ return false;
296
+ }
297
+ }
298
+
271
299
  async function discoverForCommand() {
272
300
  const env = { ...runtimeEnv };
273
301
  if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
@@ -294,7 +322,7 @@ async function configuredLocalSapGuiDestination(env) {
294
322
  const adt = (sso || !tlsServerNames.length) && !hasPathPrefix(configuredUrl)
295
323
  ? await resolveLocalAdtUrl(name, configuredUrl, client)
296
324
  : { url: configuredUrl };
297
- // 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.
298
326
  const direct = adt.verified ? noProxyEnv(env, new URL(adt.url).hostname) : {};
299
327
  const probe = adt.verified === undefined
300
328
  ? { status: 'configured', available: true }
@@ -312,7 +340,7 @@ async function configuredLocalSapGuiDestination(env) {
312
340
  systemId,
313
341
  authentication: 'SSO',
314
342
  ssoBrowsers,
315
- // 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.
316
344
  // A first sign-in has no session a hidden browser could renew, so its window opens at once.
317
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 },
318
346
  probe
@@ -349,6 +377,7 @@ async function main() {
349
377
  process.stdout.write(usage());
350
378
  return;
351
379
  }
380
+ if (!setup && !check && !list && !doctor && !demo && await handOffToConfiguredVersion()) return;
352
381
  if (doctor && demo) throw new Error('Use either --doctor or --demo, not both.');
353
382
  if (demo) {
354
383
  await runOfflineDemo();
@@ -383,14 +412,8 @@ async function main() {
383
412
  detectAdtUrl: detectLocalAdtUrl
384
413
  };
385
414
  if (hasFlag('--npx')) {
386
- // Windows has no npx.exe, so generated entries there launch through
387
- // `cmd /c npx`; other platforms use npx directly.
388
- const launcher = npxMcpLauncher();
389
- setupOptions.install = (selected, options) => installMcpConfig(selected, {
390
- ...options,
391
- command: launcher.command,
392
- args: [...launcher.prefixArgs, '--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${pkg.version}`, 'sap-ai-dev']
393
- });
415
+ const launcher = npxPackageLauncher(pkg.version);
416
+ setupOptions.install = (selected, options) => installMcpConfig(selected, { ...options, ...launcher });
394
417
  }
395
418
  await runSetup(setupOptions);
396
419
  return;
@@ -420,7 +443,7 @@ async function main() {
420
443
  let proxy;
421
444
  try {
422
445
  const binary = await binaryOrError();
423
- logLine(`[sap-ai-dev] VSP binary: ${binary}`);
446
+ logLine(`[sap-ai-dev] runtime executable: ${binary}`);
424
447
  proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine, version: pkg.version });
425
448
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
426
449
  process.once('SIGINT', () => shutdown('SIGINT'));
@@ -450,15 +473,7 @@ async function main() {
450
473
  : (/\.(?:cmd|bat)$/i.test(binary)
451
474
  ? { command: 'cmd', prefixArgs: ['/c', binary] }
452
475
  : { command: binary, prefixArgs: [] });
453
- const child = spawn(spawnTarget.command, [...spawnTarget.prefixArgs, ...process.argv.slice(2)], { env: runtimeEnv, stdio: 'inherit' });
454
- process.once('SIGINT', () => child.kill('SIGINT'));
455
- process.once('SIGTERM', () => child.kill('SIGTERM'));
456
- const [code, signal] = await new Promise((resolve, reject) => {
457
- child.once('error', reject);
458
- child.once('exit', (exitCode, exitSignal) => resolve([exitCode, exitSignal]));
459
- });
460
- if (signal) process.kill(process.pid, signal);
461
- process.exitCode = code ?? 1;
476
+ await runOnOwnStdio(spawnTarget.command, [...spawnTarget.prefixArgs, ...process.argv.slice(2)], runtimeEnv);
462
477
  return;
463
478
  }
464
479
 
@@ -477,7 +492,7 @@ async function main() {
477
492
  }
478
493
  const binary = await binaryOrError();
479
494
  logLine(`[sap-ai-dev] sap-ai-dev-toolkit v${pkg.version} (node ${process.version}, pid ${process.pid})`);
480
- logLine(`[sap-ai-dev] VSP binary: ${binary}`);
495
+ logLine(`[sap-ai-dev] runtime executable: ${binary}`);
481
496
  logLine(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
482
497
  const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine, version: pkg.version });
483
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.
@@ -131,6 +131,26 @@ export function npxMcpLauncher(platform = process.platform) {
131
131
  : { command: 'npx', prefixArgs: [] };
132
132
  }
133
133
 
134
+ export function npxPackageLauncher(version, platform = process.platform) {
135
+ const { command, prefixArgs } = npxMcpLauncher(platform);
136
+ return { command, args: [...prefixArgs, '--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit${version ? `@${version}` : ''}`, 'sap-ai-dev'] };
137
+ }
138
+
139
+ function isOlderVersion(version, other) {
140
+ const parts = value => Array.from({ length: 3 }, (_, index) => Number.parseInt(String(value).split('.')[index], 10) || 0);
141
+ const [current, target] = [parts(version), parts(other)];
142
+ const index = current.findIndex((part, position) => part !== target[position]);
143
+ return index >= 0 && current[index] < target[index];
144
+ }
145
+
146
+ // Setup pins its version into each entry, so a stale sap-ai-dev on PATH can hand off
147
+ // to that version through npx instead of running old code; the handoff happens once.
148
+ export function configuredVersionHandoff(env, runningVersion, platform = process.platform) {
149
+ const configured = brandedEnvValue(env, 'VERSION');
150
+ if (!configured || brandedEnvValue(env, 'HANDOFF') || !isOlderVersion(runningVersion, configured)) return null;
151
+ return { version: configured, ...npxPackageLauncher(configured, platform) };
152
+ }
153
+
134
154
  // Normalizes the argument list of npx-launched entries, whether written as
135
155
  // `npx ...` or the Windows `cmd /c npx ...` form; returns null when the entry
136
156
  // is not npx-launched.
@@ -150,13 +170,29 @@ function npxLaunchArgs(entry) {
150
170
  // sap-ai-dev command when it is on PATH, otherwise the pinned package
151
171
  // through npx (routed through cmd /c on Windows) so entries written by a
152
172
  // pure `npx sap-ai-dev-toolkit --setup` run still start on every host.
173
+ async function commandPackageVersion(command) {
174
+ const target = await realpath(command).catch(() => command);
175
+ for (const manifest of [join(dirname(dirname(target)), 'package.json'), join(dirname(command), 'node_modules', 'sap-ai-dev-toolkit', 'package.json')]) {
176
+ try {
177
+ const pkg = JSON.parse(await readFile(manifest, 'utf8'));
178
+ if (pkg.name === 'sap-ai-dev-toolkit') return pkg.version;
179
+ } catch {
180
+ // Try the other layout.
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
153
186
  async function resolveLauncherInstallCommand(env) {
154
187
  const resolved = await resolveMcpServerCommand(env);
155
- if (resolved !== 'sap-ai-dev') return { command: resolved, args: null };
156
- const { command, prefixArgs } = npxMcpLauncher();
157
188
  const selfVersion = await runningPackageVersion();
158
- const packageSpec = selfVersion ? `sap-ai-dev-toolkit@${selfVersion}` : 'sap-ai-dev-toolkit';
159
- return { command, args: [...prefixArgs, '--yes', '--ignore-scripts', `--package=${packageSpec}`, 'sap-ai-dev'] };
189
+ let warning;
190
+ if (resolved !== 'sap-ai-dev') {
191
+ const installed = await commandPackageVersion(resolved);
192
+ if (installed === null || !selfVersion || installed === selfVersion) return { command: resolved, args: null };
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.`;
194
+ }
195
+ return { ...npxPackageLauncher(selfVersion), warning };
160
196
  }
161
197
 
162
198
  export async function resolveMcpConfigPath(env = process.env) {
@@ -304,7 +340,7 @@ function isPackageLauncher(entry) {
304
340
  && hasPackage(npxArgs, legacyPackages);
305
341
  // Current launchers are treated as managed only when tagged by this add-on.
306
342
  // Some early bas-mcp-addon entries were not tagged with BAS_EXT, though, and
307
- // 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).
308
344
  return (entry?.BAS_EXT === 'true' && (currentCommand || currentNpxLauncher || legacyCommand || legacyNpxLauncher))
309
345
  || legacyCommand
310
346
  || legacyNpxLauncher;
@@ -552,6 +588,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
552
588
  options = destinationsOrOptions || {};
553
589
  }
554
590
  const env = options.env || process.env;
591
+ const additive = options.additive === true;
555
592
  const path = options.path || await resolveMcpConfigPath(env);
556
593
  const installWarnings = [];
557
594
  // recoverInvalid: BOM/encoding damage, VS Code JSONC comments, torn writes
@@ -576,16 +613,20 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
576
613
  const resolved = await resolveLauncherInstallCommand(env);
577
614
  launcherCommand = resolved.command;
578
615
  launcherArgs = resolved.args;
616
+ if (resolved.warning) installWarnings.push(resolved.warning);
579
617
  }
580
618
  if (launcherCommand) {
581
619
  for (const entry of Object.values(generated)) {
582
620
  if (isCompanionServer(entry)) continue;
583
621
  entry.command = launcherCommand;
584
622
  if (launcherArgs?.length) entry.args = [...launcherArgs];
623
+ if (selfVersion) entry.env.SAP_AI_DEV_TOOLKIT_VERSION = selfVersion;
585
624
  }
586
625
  }
587
626
  const servers = Object.create(null);
627
+ const managedEntries = [];
588
628
  const managedInputIds = new Set();
629
+ const generatedNames = new Set(Object.keys(generated));
589
630
  for (const [name, entry] of Object.entries(config.servers)) {
590
631
  const managed = isPackageLauncher(entry)
591
632
  && typeof destinationValue(entry?.env) === 'string'
@@ -598,17 +639,38 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
598
639
  if (match) managedInputIds.add(match[1]);
599
640
  }
600
641
  }
601
- 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;
602
647
  }
648
+ const retainedNames = new Set(managedEntries.map(([name]) => name));
603
649
  for (const name of Object.keys(generated)) {
604
- if (Object.hasOwn(servers, name)) {
605
- 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}`);
606
656
  }
607
657
  }
608
- config.servers = { ...servers, ...generated };
658
+ config.servers = { ...servers, ...Object.fromEntries(managedEntries), ...generated };
609
659
  if (generatedInputs.size || managedInputIds.size) {
610
- const existingInputs = Array.isArray(config.inputs) ? config.inputs.filter(input => !generatedInputs.has(input?.id) && !managedInputIds.has(input?.id)) : [];
611
- 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()];
612
674
  }
613
675
  await writeConfig(path, config);
614
676
  return { path, servers: generated, warnings: installWarnings };