sap-ai-dev-toolkit 0.9.97 → 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 +62 -73
- 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 +19 -19
- package/src/local-sap-gui.mjs +46 -21
- package/src/mcp-config.mjs +35 -14
- 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
|
@@ -81,15 +81,15 @@ async function runDoctor(destinations) {
|
|
|
81
81
|
checks.push(doctorRow('-', 'MCP config repair', 'failed', redactText(error.message || error).slice(0, 300)));
|
|
82
82
|
}
|
|
83
83
|
for (const destination of destinations) {
|
|
84
|
-
stopProgress.update(`Checking ${destination.name}: ADT probe,
|
|
84
|
+
stopProgress.update(`Checking ${destination.name}: ADT probe, runtime startup, tools/list`);
|
|
85
85
|
const probe = destination.probe;
|
|
86
86
|
const probeStatus = !probe || probe.status === 'skipped' ? 'skipped' : (probe.available === true ? 'passed' : 'failed');
|
|
87
87
|
checks.push(doctorRow(destination.name, 'ADT probe', probeStatus, probe?.status || 'No BAS ADT probe was supplied; GetSystemInfo will check the route.'));
|
|
88
|
-
checks.push(doctorRow(destination.name, '
|
|
88
|
+
checks.push(doctorRow(destination.name, 'Runtime executable', binary ? 'passed' : 'failed', binary ? 'installed' : binaryError));
|
|
89
89
|
if (!binary) {
|
|
90
|
-
checks.push(doctorRow(destination.name, '
|
|
91
|
-
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', '
|
|
92
|
-
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.'));
|
|
93
93
|
try { await destination.close?.(); } catch {}
|
|
94
94
|
continue;
|
|
95
95
|
}
|
|
@@ -98,7 +98,7 @@ async function runDoctor(destinations) {
|
|
|
98
98
|
proxy.start();
|
|
99
99
|
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
100
100
|
if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
|
|
101
|
-
checks.push(doctorRow(destination.name, '
|
|
101
|
+
checks.push(doctorRow(destination.name, 'MCP startup', 'passed', 'initialized'));
|
|
102
102
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
103
103
|
if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
|
|
104
104
|
const tools = listed.result?.tools || [];
|
|
@@ -109,13 +109,13 @@ async function runDoctor(destinations) {
|
|
|
109
109
|
const localSegments = new Set(['lint_abap', 'get_application_log', 'prepare_abap_change_set', 'apply_abap_change_set', 'check_transport_readiness', 'plan_abap_cloud_migration', 'generate_rap_regression_suite', 'run_rap_regression_suite']);
|
|
110
110
|
const isUpstreamTool = tool => (tool.name.startsWith(toolPrefix) ? tool.name.slice(toolPrefix.length) : tool.name);
|
|
111
111
|
const upstreamCount = tools.filter(tool => !localSegments.has(isUpstreamTool(tool))).length;
|
|
112
|
-
checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount}
|
|
112
|
+
checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} upstream tools and ${tools.length} total MCP tools returned; chat-picker binding is host-managed`));
|
|
113
113
|
const systemInfo = tools.find(tool => tool.name === 'get_system_info' || tool.name === `${toolPrefix}get_system_info`);
|
|
114
114
|
const needsAdtUrl = destination.probe?.status === 'needs-adt-url';
|
|
115
115
|
if (needsAdtUrl) {
|
|
116
116
|
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'No ADT URL is configured for this SAP GUI system; run sap-ai-dev --setup to add one.'));
|
|
117
117
|
} else if (!systemInfo) {
|
|
118
|
-
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this
|
|
118
|
+
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this runtime mode.'));
|
|
119
119
|
} else {
|
|
120
120
|
const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
|
|
121
121
|
const failed = inspected?.error || inspected?.result?.isError;
|
|
@@ -123,8 +123,8 @@ async function runDoctor(destinations) {
|
|
|
123
123
|
}
|
|
124
124
|
} catch (error) {
|
|
125
125
|
const detail = redactText(error.message || error).slice(0, 300);
|
|
126
|
-
const initialized = checks.some(row => row.name === destination.name && row.stage === '
|
|
127
|
-
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));
|
|
128
128
|
else checks.push(doctorRow(destination.name, 'MCP tools/list', 'failed', detail));
|
|
129
129
|
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'An earlier diagnostic step failed.'));
|
|
130
130
|
} finally {
|
|
@@ -179,20 +179,20 @@ async function resolveBinary() {
|
|
|
179
179
|
const notes = [];
|
|
180
180
|
const found = await findBinary(pkg, { env: runtimeEnv, log: note => notes.push(note) });
|
|
181
181
|
if (found) return found;
|
|
182
|
-
logLine(`[sap-ai-dev] ${notes.join('; ') || 'no
|
|
182
|
+
logLine(`[sap-ai-dev] ${notes.join('; ') || 'no runtime executable found'}; downloading a checksum-verified copy`);
|
|
183
183
|
try {
|
|
184
184
|
const downloaded = await downloadBinary(pkg, { env: runtimeEnv });
|
|
185
|
-
logLine(`[sap-ai-dev]
|
|
185
|
+
logLine(`[sap-ai-dev] runtime executable installed at ${downloaded}`);
|
|
186
186
|
return downloaded;
|
|
187
187
|
} catch (error) {
|
|
188
188
|
notes.push(`download fallback failed: ${error.message}${error.cause?.code ? ` (${error.cause.code})` : ''}`);
|
|
189
189
|
}
|
|
190
190
|
const npxDirectory = root.match(/^(.*[\\/]_npx[\\/][^\\/]+)[\\/]node_modules[\\/]/)?.[1];
|
|
191
191
|
throw new Error([
|
|
192
|
-
`
|
|
192
|
+
`Runtime executable ${binaryTarget().asset} is unavailable for ${pkg.name}@${pkg.version}: ${notes.join('; ')}.`,
|
|
193
193
|
npxDirectory
|
|
194
|
-
? `The npm package ships this binary, so an interrupted install or an antivirus quarantine usually removed it: delete ${npxDirectory} and restart the MCP server so npx reinstalls the package, or set SAP_AI_DEV_TOOLKIT_BINARY to a trusted
|
|
195
|
-
: `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.`
|
|
196
196
|
].join(' '));
|
|
197
197
|
}
|
|
198
198
|
|
|
@@ -322,7 +322,7 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
322
322
|
const adt = (sso || !tlsServerNames.length) && !hasPathPrefix(configuredUrl)
|
|
323
323
|
? await resolveLocalAdtUrl(name, configuredUrl, client)
|
|
324
324
|
: { url: configuredUrl };
|
|
325
|
-
// The scan reached ADT directly, so an env proxy the browser's PAC would bypass must not reroute
|
|
325
|
+
// The scan reached ADT directly, so an env proxy the browser's PAC would bypass must not reroute the runtime.
|
|
326
326
|
const direct = adt.verified ? noProxyEnv(env, new URL(adt.url).hostname) : {};
|
|
327
327
|
const probe = adt.verified === undefined
|
|
328
328
|
? { status: 'configured', available: true }
|
|
@@ -340,7 +340,7 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
340
340
|
systemId,
|
|
341
341
|
authentication: 'SSO',
|
|
342
342
|
ssoBrowsers,
|
|
343
|
-
// SAP_SAML_AUTH survives sanitizeChildEnv and would make
|
|
343
|
+
// SAP_SAML_AUTH survives sanitizeChildEnv and would make the runtime demand SAML credentials.
|
|
344
344
|
// A first sign-in has no session a hidden browser could renew, so its window opens at once.
|
|
345
345
|
childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '', SAP_SSO_FIRST_LOGIN: env.SAP_SSO_FIRST_LOGIN || 'window', SAP_SSO_SILENT_TIMEOUT: env.SAP_SSO_SILENT_TIMEOUT || '15s', ...(ssoBrowsers.length ? { SAP_BROWSER_EXEC: ssoBrowsers[0].path } : {}), ...direct },
|
|
346
346
|
probe
|
|
@@ -443,7 +443,7 @@ async function main() {
|
|
|
443
443
|
let proxy;
|
|
444
444
|
try {
|
|
445
445
|
const binary = await binaryOrError();
|
|
446
|
-
logLine(`[sap-ai-dev]
|
|
446
|
+
logLine(`[sap-ai-dev] runtime executable: ${binary}`);
|
|
447
447
|
proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine, version: pkg.version });
|
|
448
448
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
449
449
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
@@ -492,7 +492,7 @@ async function main() {
|
|
|
492
492
|
}
|
|
493
493
|
const binary = await binaryOrError();
|
|
494
494
|
logLine(`[sap-ai-dev] sap-ai-dev-toolkit v${pkg.version} (node ${process.version}, pid ${process.pid})`);
|
|
495
|
-
logLine(`[sap-ai-dev]
|
|
495
|
+
logLine(`[sap-ai-dev] runtime executable: ${binary}`);
|
|
496
496
|
logLine(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
|
|
497
497
|
const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine, version: pkg.version });
|
|
498
498
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
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
|
@@ -166,8 +166,10 @@ function npxLaunchArgs(entry) {
|
|
|
166
166
|
return null;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
//
|
|
170
|
-
//
|
|
169
|
+
// Resolves how generated destination entries launch the toolkit: the global
|
|
170
|
+
// sap-ai-dev command when it is on PATH, otherwise the pinned package
|
|
171
|
+
// through npx (routed through cmd /c on Windows) so entries written by a
|
|
172
|
+
// pure `npx sap-ai-dev-toolkit --setup` run still start on every host.
|
|
171
173
|
async function commandPackageVersion(command) {
|
|
172
174
|
const target = await realpath(command).catch(() => command);
|
|
173
175
|
for (const manifest of [join(dirname(dirname(target)), 'package.json'), join(dirname(command), 'node_modules', 'sap-ai-dev-toolkit', 'package.json')]) {
|
|
@@ -181,10 +183,6 @@ async function commandPackageVersion(command) {
|
|
|
181
183
|
return null;
|
|
182
184
|
}
|
|
183
185
|
|
|
184
|
-
// Resolves how generated destination entries launch the toolkit: the global
|
|
185
|
-
// sap-ai-dev command when it is on PATH and is this version, otherwise the
|
|
186
|
-
// pinned package through npx (routed through cmd /c on Windows) so entries
|
|
187
|
-
// written by a pure `npx sap-ai-dev-toolkit --setup` run still start on every host.
|
|
188
186
|
async function resolveLauncherInstallCommand(env) {
|
|
189
187
|
const resolved = await resolveMcpServerCommand(env);
|
|
190
188
|
const selfVersion = await runningPackageVersion();
|
|
@@ -192,7 +190,6 @@ async function resolveLauncherInstallCommand(env) {
|
|
|
192
190
|
if (resolved !== 'sap-ai-dev') {
|
|
193
191
|
const installed = await commandPackageVersion(resolved);
|
|
194
192
|
if (installed === null || !selfVersion || installed === selfVersion) return { command: resolved, args: null };
|
|
195
|
-
// A stale global install would keep starting the old launcher and VSP binary.
|
|
196
193
|
warning = `${resolved} is sap-ai-dev-toolkit ${installed}, not ${selfVersion}; MCP entries start sap-ai-dev-toolkit@${selfVersion} through npx instead. Run "npm install -g sap-ai-dev-toolkit@${selfVersion}" to use the global command.`;
|
|
197
194
|
}
|
|
198
195
|
return { ...npxPackageLauncher(selfVersion), warning };
|
|
@@ -343,7 +340,7 @@ function isPackageLauncher(entry) {
|
|
|
343
340
|
&& hasPackage(npxArgs, legacyPackages);
|
|
344
341
|
// Current launchers are treated as managed only when tagged by this add-on.
|
|
345
342
|
// Some early bas-mcp-addon entries were not tagged with BAS_EXT, though, and
|
|
346
|
-
// leaving them behind keeps advertising the old full
|
|
343
|
+
// leaving them behind keeps advertising the old full child tool surface (~159 tools).
|
|
347
344
|
return (entry?.BAS_EXT === 'true' && (currentCommand || currentNpxLauncher || legacyCommand || legacyNpxLauncher))
|
|
348
345
|
|| legacyCommand
|
|
349
346
|
|| legacyNpxLauncher;
|
|
@@ -591,6 +588,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
591
588
|
options = destinationsOrOptions || {};
|
|
592
589
|
}
|
|
593
590
|
const env = options.env || process.env;
|
|
591
|
+
const additive = options.additive === true;
|
|
594
592
|
const path = options.path || await resolveMcpConfigPath(env);
|
|
595
593
|
const installWarnings = [];
|
|
596
594
|
// recoverInvalid: BOM/encoding damage, VS Code JSONC comments, torn writes
|
|
@@ -626,7 +624,9 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
626
624
|
}
|
|
627
625
|
}
|
|
628
626
|
const servers = Object.create(null);
|
|
627
|
+
const managedEntries = [];
|
|
629
628
|
const managedInputIds = new Set();
|
|
629
|
+
const generatedNames = new Set(Object.keys(generated));
|
|
630
630
|
for (const [name, entry] of Object.entries(config.servers)) {
|
|
631
631
|
const managed = isPackageLauncher(entry)
|
|
632
632
|
&& typeof destinationValue(entry?.env) === 'string'
|
|
@@ -639,17 +639,38 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
639
639
|
if (match) managedInputIds.add(match[1]);
|
|
640
640
|
}
|
|
641
641
|
}
|
|
642
|
-
|
|
642
|
+
const exactManagedUpdate = managed && generatedNames.has(name)
|
|
643
|
+
&& destinationValue(entry.env) === generated[name]?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION
|
|
644
|
+
&& destinationSource(entry.env) === generated[name]?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE;
|
|
645
|
+
if (additive && (managed || isCompanionServer(entry)) && !exactManagedUpdate) managedEntries.push([name, entry]);
|
|
646
|
+
else if (!managed && !isCompanionServer(entry) && !LEGACY_MCP_SERVER_PREFIXES.some(prefix => name.startsWith(prefix))) servers[name] = entry;
|
|
643
647
|
}
|
|
648
|
+
const retainedNames = new Set(managedEntries.map(([name]) => name));
|
|
644
649
|
for (const name of Object.keys(generated)) {
|
|
645
|
-
if (Object.hasOwn(servers, name)) {
|
|
646
|
-
|
|
650
|
+
if (Object.hasOwn(servers, name) || retainedNames.has(name)) {
|
|
651
|
+
const existing = Object.hasOwn(servers, name) ? servers[name] : config.servers[name];
|
|
652
|
+
const conflict = Object.hasOwn(servers, name)
|
|
653
|
+
? 'already exists and is not managed by sap-ai-dev-toolkit'
|
|
654
|
+
: `conflicts with a different managed destination${isCompanionServer(existing) ? ' or companion server' : ''}`;
|
|
655
|
+
throw new Error(`MCP server "${name}" ${conflict}`);
|
|
647
656
|
}
|
|
648
657
|
}
|
|
649
|
-
config.servers = { ...servers, ...generated };
|
|
658
|
+
config.servers = { ...servers, ...Object.fromEntries(managedEntries), ...generated };
|
|
650
659
|
if (generatedInputs.size || managedInputIds.size) {
|
|
651
|
-
const
|
|
652
|
-
|
|
660
|
+
const retainedInputIds = new Set();
|
|
661
|
+
if (additive) {
|
|
662
|
+
for (const [, entry] of managedEntries) {
|
|
663
|
+
for (const value of Object.values(entry.env || {})) {
|
|
664
|
+
const match = typeof value === 'string' ? value.match(/^\$\{input:([^}]+)}$/) : null;
|
|
665
|
+
if (match) retainedInputIds.add(match[1]);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const existingInputs = Array.isArray(config.inputs) ? config.inputs.filter(input =>
|
|
670
|
+
!managedInputIds.has(input?.id) || retainedInputIds.has(input?.id)) : [];
|
|
671
|
+
const inputsById = new Map(existingInputs.map(input => [input?.id, input]));
|
|
672
|
+
for (const [id, input] of generatedInputs) inputsById.set(id, input);
|
|
673
|
+
config.inputs = [...inputsById.values()];
|
|
653
674
|
}
|
|
654
675
|
await writeConfig(path, config);
|
|
655
676
|
return { path, servers: generated, warnings: installWarnings };
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -45,10 +45,13 @@ const APPLICATION_LOG_DESCRIPTION = 'Read SAP application log (SLG1) entries. Re
|
|
|
45
45
|
const NON_RETRIABLE_VSP_TOOLS = new Set([
|
|
46
46
|
'Activate',
|
|
47
47
|
'ActivateMultiple',
|
|
48
|
+
'CreateObject',
|
|
48
49
|
'CreateTransport',
|
|
49
50
|
'EditSource',
|
|
50
51
|
'LockObject',
|
|
52
|
+
'PublishServiceBinding',
|
|
51
53
|
'UnlockObject',
|
|
54
|
+
'UnpublishServiceBinding',
|
|
52
55
|
'WriteSource'
|
|
53
56
|
]);
|
|
54
57
|
|
|
@@ -68,12 +71,13 @@ function requestTimeoutMs(env) {
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
|
|
71
|
-
// Keep the default public
|
|
74
|
+
// Keep the default public tool surface small enough for developer-lifecycle use.
|
|
72
75
|
// Hidden upstream tools can still be used by local workflow tools when needed.
|
|
73
76
|
export const PUBLIC_VSP_TOOLS = new Set([
|
|
74
77
|
'Activate',
|
|
75
78
|
'ActivateMultiple',
|
|
76
79
|
'CompareSource',
|
|
80
|
+
'CreateObject',
|
|
77
81
|
'CreateTransport',
|
|
78
82
|
'DebuggerAttach',
|
|
79
83
|
'DebuggerDetach',
|
|
@@ -116,13 +120,19 @@ export const PUBLIC_VSP_TOOLS = new Set([
|
|
|
116
120
|
'ListTransports',
|
|
117
121
|
'LockObject',
|
|
118
122
|
'PrettyPrint',
|
|
123
|
+
'PublishServiceBinding',
|
|
119
124
|
'RunATCCheck',
|
|
120
125
|
'RunQuery',
|
|
121
126
|
'RunUnitTests',
|
|
122
127
|
'SearchObject',
|
|
123
128
|
'SetBreakpoint',
|
|
124
129
|
'SyntaxCheck',
|
|
130
|
+
// UI5 write tools stay hidden: the ADT filestore is read-only.
|
|
131
|
+
'UI5GetApp',
|
|
132
|
+
'UI5GetFileContent',
|
|
133
|
+
'UI5ListApps',
|
|
125
134
|
'UnlockObject',
|
|
135
|
+
'UnpublishServiceBinding',
|
|
126
136
|
'WriteSource'
|
|
127
137
|
]);
|
|
128
138
|
|
|
@@ -175,7 +185,7 @@ function useProxyAuthentication(destination) {
|
|
|
175
185
|
function childEnvironment(destination, env) {
|
|
176
186
|
const childEnv = { ...sanitizeChildEnv(env), ...(destination.childEnv || {}) };
|
|
177
187
|
if (useProxyAuthentication(destination)) {
|
|
178
|
-
//
|
|
188
|
+
// The runtime loads SAP_* values from local .env files. Set every supported local
|
|
179
189
|
// auth variable explicitly empty so dotenv cannot supply a fallback
|
|
180
190
|
// identity or session instead of the selected BAS destination identity.
|
|
181
191
|
for (const key of [
|
|
@@ -200,7 +210,7 @@ export function childArguments(destination, env = process.env) {
|
|
|
200
210
|
|
|
201
211
|
// Windows cannot exec a .js/.mjs file directly (no shebang support), so test
|
|
202
212
|
// fixtures written in JavaScript are launched through the current Node binary.
|
|
203
|
-
// The production
|
|
213
|
+
// The production runtime executable is native and unaffected; a
|
|
204
214
|
// Windows .cmd/.bat override needs cmd /c because Node refuses to spawn
|
|
205
215
|
// batch files directly (EINVAL since the CVE-2024-27980 hardening).
|
|
206
216
|
function spawnCommand(binary) {
|
|
@@ -237,16 +247,16 @@ class Child {
|
|
|
237
247
|
// Without a listener that is an uncaught exception that kills the whole
|
|
238
248
|
// MCP server — exactly the crash window self-healing is meant to cover.
|
|
239
249
|
this.process.stdin.on('error', error => {
|
|
240
|
-
if (!this.closing && this.options.log) this.options.log(`[${destination.name}]
|
|
250
|
+
if (!this.closing && this.options.log) this.options.log(`[${destination.name}] runtime process stdin error: ${diagnosticText(error.message)}`);
|
|
241
251
|
this.fail(new Error(`child stdin failed: ${error.message}`));
|
|
242
252
|
});
|
|
243
253
|
this.process.on('error', error => {
|
|
244
|
-
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}]
|
|
254
|
+
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] runtime process error: ${diagnosticText(error.message)}`);
|
|
245
255
|
this.fail(error);
|
|
246
256
|
void closeDestinationRoute(destination);
|
|
247
257
|
});
|
|
248
258
|
this.process.on('exit', (code, signal) => {
|
|
249
|
-
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}]
|
|
259
|
+
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] runtime process exited (${code ?? signal})`);
|
|
250
260
|
this.fail(new Error(`child exited (${code ?? signal})`));
|
|
251
261
|
void closeDestinationRoute(destination);
|
|
252
262
|
});
|
|
@@ -327,7 +337,7 @@ class Child {
|
|
|
327
337
|
try { this.process.stdin.write(`${JSON.stringify({ jsonrpc: JSONRPC, method, ...(params === undefined ? {} : { params })})}\n`); } catch {}
|
|
328
338
|
}
|
|
329
339
|
|
|
330
|
-
//
|
|
340
|
+
// The runtime's own "Error:" line explains an exit, and stderr can still be draining when 'exit' fires.
|
|
331
341
|
async stopReason(waitMs = 1000) {
|
|
332
342
|
let timer;
|
|
333
343
|
await Promise.race([this.stderrClosed, new Promise(resolve => { timer = setTimeout(resolve, waitMs); })]);
|
|
@@ -352,7 +362,7 @@ class Child {
|
|
|
352
362
|
return tools;
|
|
353
363
|
}
|
|
354
364
|
|
|
355
|
-
// The
|
|
365
|
+
// The runtime tool surface is fixed for the process lifetime (--mode is a
|
|
356
366
|
// launch argument), so listings are memoized per child. The memo is
|
|
357
367
|
// cleared on failure, on child exit, and on upstream list_changed.
|
|
358
368
|
listToolsCached() {
|
|
@@ -465,7 +475,7 @@ export class MCPProxy {
|
|
|
465
475
|
// keeping serverInfo's children[0] fallback and merged slug numbering deterministic.
|
|
466
476
|
const started = await Promise.all(this.destinations.map(destination => (async () => {
|
|
467
477
|
try {
|
|
468
|
-
this.eventSink(`[${destination.name}] starting
|
|
478
|
+
this.eventSink(`[${destination.name}] starting runtime process (client=${destination.client || '001'})`);
|
|
469
479
|
return {
|
|
470
480
|
destination,
|
|
471
481
|
child: new Child(this.binary, destination, this.childSpawnOptions(destination))
|
|
@@ -491,7 +501,7 @@ export class MCPProxy {
|
|
|
491
501
|
return this;
|
|
492
502
|
}
|
|
493
503
|
|
|
494
|
-
// Self-healing mode 3: transparently restart a crashed
|
|
504
|
+
// Self-healing mode 3: transparently restart a crashed child so an
|
|
495
505
|
// MCP tools/call does not permanently fail after a single crash. The
|
|
496
506
|
// restarted child re-initializes and re-registers tools before the call
|
|
497
507
|
// is retried once. Concurrent triggers share one restart (no orphaned
|
|
@@ -505,11 +515,11 @@ export class MCPProxy {
|
|
|
505
515
|
const now = Date.now();
|
|
506
516
|
state.timestamps = state.timestamps.filter(at => now - at < RESTART_WINDOW_MS);
|
|
507
517
|
if (state.timestamps.length >= MAX_RESTARTS_PER_WINDOW) {
|
|
508
|
-
throw new Error(`
|
|
518
|
+
throw new Error(`Runtime process restart budget exhausted (${MAX_RESTARTS_PER_WINDOW} in ${RESTART_WINDOW_MS / 1000}s); surfacing the failure instead of respawning`);
|
|
509
519
|
}
|
|
510
520
|
state.inFlight = (async () => {
|
|
511
521
|
const name = entry.destination.name;
|
|
512
|
-
this.eventSink(`[${name}]
|
|
522
|
+
this.eventSink(`[${name}] runtime process crashed; self-healing restart in progress`, 'warning');
|
|
513
523
|
try {
|
|
514
524
|
await entry.child.close().catch(() => {});
|
|
515
525
|
const child = new Child(this.binary, entry.destination, this.childSpawnOptions(entry.destination));
|
|
@@ -518,7 +528,7 @@ export class MCPProxy {
|
|
|
518
528
|
this.toolsCache = null;
|
|
519
529
|
await child.listToolsCached().catch(() => {});
|
|
520
530
|
state.timestamps.push(Date.now());
|
|
521
|
-
this.eventSink(`[${name}]
|
|
531
|
+
this.eventSink(`[${name}] runtime process self-healing restart complete`);
|
|
522
532
|
this.emitToolsListChanged();
|
|
523
533
|
return entry;
|
|
524
534
|
} finally {
|
|
@@ -544,16 +554,16 @@ export class MCPProxy {
|
|
|
544
554
|
const initialized = await Promise.all(this.children.map(async entry => {
|
|
545
555
|
const { destination } = entry;
|
|
546
556
|
const startedAt = Date.now();
|
|
547
|
-
this.eventSink(`[${destination.name}] initializing
|
|
557
|
+
this.eventSink(`[${destination.name}] initializing runtime MCP session`);
|
|
548
558
|
for (let attempt = 1; ; attempt += 1) {
|
|
549
559
|
const attemptStartedAt = Date.now();
|
|
550
|
-
//
|
|
560
|
+
// The runtime answers initialize only after browser sign-in, which waits on a person.
|
|
551
561
|
const signIn = destination.authentication === 'SSO'
|
|
552
562
|
? setInterval(() => this.eventSink(`[${destination.name}] still waiting for the SSO sign-in (${Math.round((Date.now() - attemptStartedAt) / 1000)} s): finish it in the browser window (it may be behind other windows); restarting the server cancels it`), this.ssoHeartbeatMs)
|
|
553
563
|
: undefined;
|
|
554
564
|
try {
|
|
555
565
|
entry.server = await entry.child.initialize(params).finally(() => clearInterval(signIn));
|
|
556
|
-
this.eventSink(`[${destination.name}]
|
|
566
|
+
this.eventSink(`[${destination.name}] runtime MCP session initialized (${Date.now() - startedAt}ms)`);
|
|
557
567
|
return entry;
|
|
558
568
|
} catch (error) {
|
|
559
569
|
const reason = await entry.child.stopReason();
|
package/src/setup.mjs
CHANGED
|
@@ -705,6 +705,7 @@ export async function runSetup({
|
|
|
705
705
|
return { skipped: true, reason: 'no-destinations', warnings: [...warnings, ...cleanupWarnings] };
|
|
706
706
|
};
|
|
707
707
|
let selected;
|
|
708
|
+
let additiveSetup = false;
|
|
708
709
|
if (!selectable.length) {
|
|
709
710
|
// Nothing was detected — no BAS/CF destinations, or no SAP GUI entries.
|
|
710
711
|
// Offer the manual MCP entry wizard before giving up.
|
|
@@ -721,9 +722,11 @@ export async function runSetup({
|
|
|
721
722
|
}, { input, output });
|
|
722
723
|
if (choice !== 'manual') return await exitWithoutDestinations();
|
|
723
724
|
selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
|
|
725
|
+
additiveSetup = true;
|
|
724
726
|
if (!selected.length) return await exitWithoutDestinations();
|
|
725
727
|
} else {
|
|
726
728
|
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl, previousAdtUrls });
|
|
729
|
+
additiveSetup = selected.length > 0;
|
|
727
730
|
}
|
|
728
731
|
|
|
729
732
|
let sapDevelopmentServers = [];
|
|
@@ -746,7 +749,7 @@ export async function runSetup({
|
|
|
746
749
|
}
|
|
747
750
|
|
|
748
751
|
try {
|
|
749
|
-
const result = await install(selected, { env, sapDevelopmentServers });
|
|
752
|
+
const result = await install(selected, { env, sapDevelopmentServers, additive: additiveSetup });
|
|
750
753
|
const location = result?.path ? ` in ${result.path}` : '';
|
|
751
754
|
print(output, '');
|
|
752
755
|
const configuredCount = selected.length + sapDevelopmentServers.length;
|
package/src/sso-browsers.mjs
CHANGED
|
@@ -59,7 +59,7 @@ async function fileExists(path) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
// Every Chromium-based browser
|
|
62
|
+
// Every Chromium-based browser the runtime can drive, best first; SAP_BROWSER_EXEC pins one ahead of the rest.
|
|
63
63
|
export async function findSsoBrowsers({ env = process.env, platform = process.platform, exists = fileExists, appPaths = registeredAppPaths } = {}) {
|
|
64
64
|
const candidates = [];
|
|
65
65
|
const pinned = envValue(env, 'SAP_BROWSER_EXEC');
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -274,17 +274,19 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
274
274
|
const render = (error = '') => {
|
|
275
275
|
const columns = Number.isFinite(output?.columns) ? output.columns : 80;
|
|
276
276
|
const indexes = visibleIndexes();
|
|
277
|
+
const fixedLines = 1 + (searchable ? 1 : 0) + (Object.hasOwn(actions, 'm') ? 1 : 0) + footer().length;
|
|
277
278
|
const pageSize = searchable
|
|
278
|
-
? Math.max(1,
|
|
279
|
+
? Math.max(1, Math.min(indexes.length, (Number.isFinite(output?.rows) ? output.rows : 24) - fixedLines - 7))
|
|
279
280
|
: checkboxPageSize(output, indexes.length);
|
|
280
281
|
const cursorPosition = indexes.indexOf(cursor);
|
|
281
282
|
ensureVisible(pageSize, indexes.length, cursorPosition);
|
|
282
283
|
const lines = [];
|
|
283
284
|
lines.push(truncateToColumns(`${message} (Space: select, ${shortcuts?.all || 'a'}: toggle all, Enter: confirm)`, columns));
|
|
284
285
|
if (searchable) {
|
|
285
|
-
const hint = searchQuery ? 'Backspace edits; Esc clears' : 'type prefix
|
|
286
|
+
const hint = searchQuery ? 'Backspace edits; Esc clears' : 'type prefix to search';
|
|
286
287
|
lines.push(truncateToColumns(`Search: ${searchQuery || 'type prefix'} (${hint})`, columns));
|
|
287
288
|
}
|
|
289
|
+
if (Object.hasOwn(actions, 'm')) lines.push(truncateToColumns('m: add system manually', columns));
|
|
288
290
|
const end = Math.min(indexes.length, top + pageSize);
|
|
289
291
|
for (let index = top; index < end; index += 1) {
|
|
290
292
|
const choiceIndex = indexes[index];
|
|
@@ -505,7 +507,7 @@ export async function textPrompt({ message, secret = false, required = true, pla
|
|
|
505
507
|
}
|
|
506
508
|
|
|
507
509
|
// Animated progress line for long-running steps (destination discovery,
|
|
508
|
-
// ADT probes,
|
|
510
|
+
// ADT probes, runtime provisioning). TTY outputs get spinner frames on an
|
|
509
511
|
// unref'd timer that starts after a short delay, so steps that finish quickly
|
|
510
512
|
// never flash anything. Non-TTY outputs (pipes, MCP hosts, --json callers,
|
|
511
513
|
// CI) stay completely silent so machine consumers never see progress noise.
|