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 +1 -1
- package/scripts/install-local.mjs +7 -2
- package/src/hana-inspector.mjs +0 -0
- package/src/mcp-config.mjs +50 -11
- package/test/mcp-config-cf.test.mjs +46 -0
- package/test/setup.test.mjs +1 -1
package/package.json
CHANGED
|
@@ -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(`
|
|
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;
|
package/src/hana-inspector.mjs
CHANGED
|
File without changes
|
package/src/mcp-config.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
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
|
|
153
|
-
const
|
|
154
|
-
const
|
|
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 =>
|
|
157
|
-
&&
|
|
158
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
});
|
package/test/setup.test.mjs
CHANGED
|
@@ -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.
|
|
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)
|