sap-ai-dev-toolkit 0.4.0 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
@@ -61,6 +61,9 @@ async function verify() {
61
61
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
62
62
  const prefix = await resolveGlobalBinaryPath();
63
63
  const expected = resolvePath(prefix, 'lib', 'node_modules', pkg.name);
64
+ const serverCommand = process.platform === 'win32'
65
+ ? join(prefix, 'sap-ai-dev.cmd')
66
+ : join(prefix, 'bin', 'sap-ai-dev');
64
67
  const installedRoot = await new Promise((resolve, reject) => {
65
68
  const child = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['list', '-g', pkg.name, '--parseable'], {
66
69
  cwd: root,
@@ -73,14 +76,16 @@ async function verify() {
73
76
  child.once('error', reject);
74
77
  child.once('close', code => resolve(code === 0 ? stdout.trim().split('\n').filter(Boolean).pop() : null));
75
78
  });
76
- console.log(`Global prefix: ${prefix}`);
77
- console.log(`Installed location: ${installedRoot || '(not installed)'}`);
79
+ console.log(`MCP server location: ${serverCommand}`);
78
80
  console.log(`Workspace root: ${root}`);
79
81
  if (!installedRoot) throw new Error(`${pkg.name} is not installed globally`);
80
82
  if (installedRoot !== expected) {
81
83
  console.log(`Expected location: ${expected}`);
82
84
  throw new Error('Global installation does not match the expected npm layout');
83
85
  }
86
+ if (!await lstat(serverCommand).catch(() => null)) {
87
+ throw new Error(`Global sap-ai-dev command is missing at ${serverCommand}`);
88
+ }
84
89
  const stats = await lstat(installedRoot).catch(() => null);
85
90
  console.log(`Install mode: ${stats?.isSymbolicLink() ? 'npm link (live workspace)' : 'tarball (snapshot)'}`);
86
91
  return 0;
File without changes
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
4
  import { homedir } from 'node:os';
4
- import { basename, dirname, join } from 'node:path';
5
+ import { basename, delimiter, dirname, join, resolve } from 'node:path';
5
6
  import { brandedEnvValue } from './branding.mjs';
6
7
 
7
8
  const LEGACY_MCP_SERVER_PREFIXES = ['sapAiDev_', 'basVspMcp_'];
@@ -61,6 +62,25 @@ async function exists(path) {
61
62
  }
62
63
  }
63
64
 
65
+ export async function resolveMcpServerCommand(env = process.env) {
66
+ const executableNames = process.platform === 'win32'
67
+ ? ['sap-ai-dev.cmd', 'sap-ai-dev.exe', 'sap-ai-dev']
68
+ : ['sap-ai-dev'];
69
+ const searchPath = String(env.PATH || env.Path || '').split(delimiter).filter(Boolean);
70
+ for (const directory of searchPath) {
71
+ for (const executableName of executableNames) {
72
+ const candidate = resolve(directory, executableName);
73
+ try {
74
+ await access(candidate, constants.X_OK);
75
+ return candidate;
76
+ } catch {
77
+ // Continue searching PATH; use the command name as a portable fallback.
78
+ }
79
+ }
80
+ }
81
+ return 'sap-ai-dev';
82
+ }
83
+
64
84
  export async function resolveMcpConfigPath(env = process.env) {
65
85
  const configuredPath = brandedEnvValue(env, 'MCP_CONFIG');
66
86
  if (configuredPath) return configuredPath;
@@ -149,13 +169,31 @@ function isCompanionServer(entry) {
149
169
  }
150
170
 
151
171
  function isPackageLauncher(entry) {
152
- const commands = new Set(['sap-ai-dev', 'sap-ai-dev-toolkit', 'bas-vsp-mcp']);
153
- const packages = [/^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/, /^--package=bas-mcp-addon(?:@[^/]+)?$/];
154
- const npxLauncher = entry?.command === 'npx'
172
+ const currentCommands = new Set(['sap-ai-dev', 'sap-ai-dev-toolkit']);
173
+ const legacyCommands = new Set(['bas-vsp-mcp']);
174
+ const commandName = typeof entry?.command === 'string'
175
+ ? entry.command.slice(Math.max(entry.command.lastIndexOf('/'), entry.command.lastIndexOf('\\')) + 1).replace(/\.(?:cmd|exe)$/i, '')
176
+ : '';
177
+ const currentCommand = currentCommands.has(entry?.command) || currentCommands.has(commandName);
178
+ const legacyCommand = legacyCommands.has(entry?.command) || legacyCommands.has(commandName);
179
+ const currentPackages = [/^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/];
180
+ const legacyPackages = [/^--package=bas-mcp-addon(?:@[^/]+)?$/];
181
+ const hasPackage = patterns => Array.isArray(entry?.args)
182
+ && entry.args.some(argument => typeof argument === 'string' && patterns.some(pattern => pattern.test(argument)));
183
+ const currentNpxLauncher = entry?.command === 'npx'
184
+ && Array.isArray(entry.args)
185
+ && entry.args.some(argument => currentCommands.has(argument))
186
+ && hasPackage(currentPackages);
187
+ const legacyNpxLauncher = entry?.command === 'npx'
155
188
  && Array.isArray(entry.args)
156
- && entry.args.some(argument => commands.has(argument))
157
- && entry.args.some(argument => typeof argument === 'string' && packages.some(pattern => pattern.test(argument)));
158
- return entry?.BAS_EXT === 'true' && (commands.has(entry?.command) || npxLauncher);
189
+ && entry.args.some(argument => legacyCommands.has(argument))
190
+ && hasPackage(legacyPackages);
191
+ // Current launchers are treated as managed only when tagged by this add-on.
192
+ // Some early bas-mcp-addon entries were not tagged with BAS_EXT, though, and
193
+ // leaving them behind keeps advertising the old full VSP surface (~159 tools).
194
+ return (entry?.BAS_EXT === 'true' && (currentCommand || currentNpxLauncher || legacyCommand || legacyNpxLauncher))
195
+ || legacyCommand
196
+ || legacyNpxLauncher;
159
197
  }
160
198
 
161
199
  function destinationValue(env) {
@@ -240,11 +278,12 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
240
278
  ...buildMcpEntries(destinations, env),
241
279
  ...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [])
242
280
  };
243
- if (options.command) {
281
+ const launcherCommand = options.command || (destinations.length ? await resolveMcpServerCommand(env) : null);
282
+ if (launcherCommand) {
244
283
  for (const entry of Object.values(generated)) {
245
284
  if (isCompanionServer(entry)) continue;
246
- entry.command = options.command;
247
- if (options.args?.length) entry.args = [...options.args];
285
+ entry.command = launcherCommand;
286
+ if (options.command && options.args?.length) entry.args = [...options.args];
248
287
  }
249
288
  }
250
289
  const servers = Object.create(null);
@@ -61,6 +61,23 @@ test('writes source-qualified CF entries beside same-named BAS destinations', ()
61
61
  assert.equal(onPremise['cf:space-one:onprem-instance:shared'].env.BAS_CF_CONNECTIVITY_KEY, 'connectivity-key');
62
62
  });
63
63
 
64
+ test('stores and reconciles the MCP launcher executable location', async t => {
65
+ const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-launcher-location-'));
66
+ const path = join(directory, 'mcp.json');
67
+ const command = join(directory, 'sap-ai-dev');
68
+ await writeFile(command, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
69
+ await writeFile(path, JSON.stringify({ servers: { userServer: { command: 'custom-server' } } }));
70
+ t.after(() => rm(directory, { recursive: true, force: true }));
71
+
72
+ const env = { H2O_URL: 'http://h2o.example', PATH: directory };
73
+ const installed = await installMcpConfig([bas], { env, path });
74
+ assert.equal(installed.servers.shared.command, command);
75
+ assert.equal((await readMcpConfig(path)).servers.shared.command, command);
76
+
77
+ await installMcpConfig([], { env, path });
78
+ assert.deepEqual(Object.keys((await readMcpConfig(path)).servers), ['userServer']);
79
+ });
80
+
64
81
  test('extracts key references only from package-managed Cloud Foundry entries', () => {
65
82
  const cloudFoundry = buildMcpEntries([
66
83
  cfDestination('space-one', 'instance-one', 'destination-key', 'OnPremise')
@@ -225,9 +242,38 @@ test('recognizes old npx Cloud Foundry entries so setup can reuse their service
225
242
  BAS_VSP_DESTINATION: current.env.SAP_AI_DEV_TOOLKIT_DESTINATION
226
243
  }
227
244
  };
245
+ delete legacy.BAS_EXT;
228
246
  delete legacy.env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE;
229
247
  delete legacy.env.SAP_AI_DEV_TOOLKIT_DESTINATION;
230
248
  assert.deepEqual(collectManagedCloudFoundryKeyReferences({ servers: { legacy } }), [
231
249
  { kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'old-key' }
232
250
  ]);
233
251
  });
252
+
253
+ test('setup removes untagged legacy bas-mcp-addon launchers that expose the full VSP surface', async t => {
254
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-untagged-legacy-'));
255
+ const path = join(directory, 'mcp.json');
256
+ t.after(() => rm(directory, { recursive: true, force: true }));
257
+ await writeFile(path, JSON.stringify({
258
+ servers: {
259
+ shared: {
260
+ type: 'stdio',
261
+ command: 'npx',
262
+ args: ['--yes', '--package=bas-mcp-addon@0.2.0', 'bas-vsp-mcp'],
263
+ env: { H2O_URL: 'http://old-h2o.example', BAS_VSP_DESTINATION: 'shared' }
264
+ },
265
+ legacyCommand: {
266
+ type: 'stdio',
267
+ command: 'bas-vsp-mcp',
268
+ env: { H2O_URL: 'http://old-h2o.example', BAS_VSP_DESTINATION: 'other' }
269
+ },
270
+ userServer: { type: 'stdio', command: 'custom-server' }
271
+ }
272
+ }));
273
+
274
+ await installMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example' }, path });
275
+ const migrated = await readMcpConfig(path);
276
+ assert.deepEqual(Object.keys(migrated.servers).sort(), ['shared', 'userServer']);
277
+ assert.equal(migrated.servers.shared.command, 'sap-ai-dev');
278
+ assert.equal(migrated.servers.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
279
+ });
@@ -306,7 +306,7 @@ test('global postinstall completes BAS selection before optional Copilot assets'
306
306
  assert.ok(acceptLogs.indexOf('Installation configuration summary') > acceptLogs.indexOf('Installed 19 Copilot files'), acceptLogs);
307
307
  assert.ok(acceptLogs.includes(`MCP config file: ${config}`), acceptLogs);
308
308
  assert.ok(acceptLogs.includes('Destination: BAS · alpha-system · client 100 · Basic'), acceptLogs);
309
- assert.ok(acceptLogs.includes('Launch: stdio · sap-ai-dev'), acceptLogs);
309
+ assert.match(acceptLogs, /Launch: stdio · (?:[^\r\n]*[\\/])?sap-ai-dev(?:\.cmd)?(?:\r?\n|$)/, acceptLogs);
310
310
  assert.ok(acceptLogs.includes('Environment keys: H2O_URL, SAP_AI_DEV_TOOLKIT_DESTINATION, SAP_ALLOW_TRANSPORTABLE_EDITS'), acceptLogs);
311
311
  const configAfterAccept = JSON.parse(await readFile(config, 'utf8'));
312
312
  assert.deepEqual(Object.values(configAfterAccept.servers)