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.
- package/.github/agents/abap-developer.agent.md +2 -2
- package/.github/agents/abap-runtime-debugger.agent.md +1 -1
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +3 -2
- package/.github/agents/rap-service-developer.agent.md +4 -4
- package/.github/agents/sap-solution-architect.agent.md +3 -3
- package/.github/skills/abap-debugging/SKILL.md +1 -1
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +1 -1
- package/.github/skills/abap-testing-quality/SKILL.md +2 -2
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +1 -1
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +2 -2
- package/.github/skills/rap-service-delivery/SKILL.md +2 -2
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +61 -70
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/inventory.md +25 -15
- package/package.json +1 -1
- package/patches/vsp-bas-proxy-auth.patch +342 -0
- package/scripts/build-vsp.mjs +2 -2
- package/scripts/postinstall.mjs +22 -11
- package/scripts/publish-npm.mjs +4 -4
- package/src/binary.mjs +14 -14
- package/src/launcher.mjs +52 -37
- package/src/local-sap-gui.mjs +46 -21
- package/src/mcp-config.mjs +73 -11
- package/src/mcp-proxy.mjs +26 -16
- package/src/setup.mjs +4 -1
- package/src/sso-browsers.mjs +1 -1
- package/src/terminal-ui.mjs +5 -3
- 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,
|
|
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,
|
|
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, '
|
|
88
|
+
checks.push(doctorRow(destination.name, 'Runtime executable', binary ? 'passed' : 'failed', binary ? 'installed' : binaryError));
|
|
88
89
|
if (!binary) {
|
|
89
|
-
checks.push(doctorRow(destination.name, '
|
|
90
|
-
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', '
|
|
91
|
-
checks.push(doctorRow(destination.name, 'MCP tools/list', 'skipped', '
|
|
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, '
|
|
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}
|
|
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
|
|
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 === '
|
|
126
|
-
if (!initialized) checks.push(doctorRow(destination.name, '
|
|
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
|
|
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]
|
|
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
|
-
`
|
|
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
|
|
194
|
-
: `Reinstall ${pkg.name} (a git checkout
|
|
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
|
|
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
|
|
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
|
-
|
|
387
|
-
|
|
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]
|
|
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
|
-
|
|
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]
|
|
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)); };
|
package/src/local-sap-gui.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
185
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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;
|
|
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: `
|
|
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: '
|
|
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.
|
package/src/mcp-config.mjs
CHANGED
|
@@ -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
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
611
|
-
|
|
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 };
|