sap-ai-dev-toolkit 0.5.0 → 0.5.6
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 +3 -3
- package/.github/agents/abap-runtime-debugger.agent.md +3 -3
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-debugging/SKILL.md +3 -3
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +2 -2
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +17 -11
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +17 -18
- package/src/bas-destination-relay.mjs +58 -22
- package/src/bas-discovery.mjs +24 -8
- package/src/binary.mjs +101 -17
- package/src/cf-destination.mjs +55 -1
- package/src/credentials-store.mjs +0 -10
- package/src/engineering-tools.mjs +32 -10
- package/src/hana-tools.mjs +9 -2
- package/src/launcher.mjs +22 -19
- package/src/mcp-config.mjs +41 -7
- package/src/mcp-proxy.mjs +392 -104
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +33 -3
- package/tools.md +4 -2
- package/test/bas-destination-relay.test.mjs +0 -374
- package/test/cf-connectivity.test.mjs +0 -287
- package/test/cf-destination.test.mjs +0 -307
- package/test/cf-runtime.test.mjs +0 -527
- package/test/copilot-assets.test.mjs +0 -63
- package/test/copilot-content.test.mjs +0 -249
- package/test/credentials-store.test.mjs +0 -187
- package/test/discovery.test.mjs +0 -257
- package/test/fixtures/fake-vsp.mjs +0 -263
- package/test/hana-config.test.mjs +0 -168
- package/test/hana-inspector-stdio.test.mjs +0 -44
- package/test/hana-tools.test.mjs +0 -206
- package/test/launcher.test.mjs +0 -399
- package/test/live-s4h.test.mjs +0 -202
- package/test/mcp-config-cf.test.mjs +0 -407
- package/test/mcp-proxy.test.mjs +0 -409
- package/test/pty.mjs +0 -8
- package/test/setup-cf.test.mjs +0 -489
- package/test/setup.test.mjs +0 -417
- package/test/terminal-ui.test.mjs +0 -47
package/src/binary.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir, rename, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { brandedEnvValue } from './branding.mjs';
|
|
7
7
|
|
|
8
8
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
9
|
+
// Single source of truth for the upstream VSP commit; scripts/build-vsp.mjs
|
|
10
|
+
// imports this constant so the built artifact and the runtime cannot drift.
|
|
9
11
|
export const UPSTREAM_COMMIT = '9886d2727f47506368b0a3c2f1c1766f1200f747';
|
|
10
12
|
|
|
11
13
|
const platformMap = {
|
|
@@ -26,6 +28,46 @@ export function bundledBinaryPath(platform = process.platform, arch = process.ar
|
|
|
26
28
|
return join(packageRoot, 'dist', binaryTarget(platform, arch).asset);
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
// The npm-published dist/checksums.txt is the trust anchor: unlike a GitHub
|
|
32
|
+
// release (mutable) or a downloaded checksum file (same origin as the
|
|
33
|
+
// binary), its content is fixed the moment the package version is published.
|
|
34
|
+
let packagedChecksumsCache;
|
|
35
|
+
async function packagedChecksums() {
|
|
36
|
+
if (packagedChecksumsCache !== undefined) return packagedChecksumsCache;
|
|
37
|
+
const map = new Map();
|
|
38
|
+
try {
|
|
39
|
+
const text = await readFile(join(packageRoot, 'dist', 'checksums.txt'), 'utf8');
|
|
40
|
+
for (const line of text.split(/\r?\n/)) {
|
|
41
|
+
const parts = line.trim().split(/\s+/);
|
|
42
|
+
if (parts.length >= 2) map.set(parts[1], parts[0].toLowerCase());
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Development checkouts without a dist/ fall through to the remote
|
|
46
|
+
// release checksum during install; local candidates stay unverified.
|
|
47
|
+
}
|
|
48
|
+
packagedChecksumsCache = map;
|
|
49
|
+
return map;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function sha256File(path) {
|
|
53
|
+
const bytes = await readFile(path);
|
|
54
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Verifies a locally present binary against the packaged checksum manifest.
|
|
58
|
+
// Returns true (verified), false (mismatch — must not be executed), or null
|
|
59
|
+
// (no packaged entry; caller decides whether to trust the candidate).
|
|
60
|
+
async function verifyAgainstPackagedChecksums(path, target) {
|
|
61
|
+
const checksums = await packagedChecksums();
|
|
62
|
+
const expected = checksums.get(target.asset);
|
|
63
|
+
if (!expected) return null;
|
|
64
|
+
try {
|
|
65
|
+
return (await sha256File(path)) === expected;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
29
71
|
export function packageRepositoryUrl(pkg) {
|
|
30
72
|
const raw = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
|
|
31
73
|
if (!raw) throw new Error('package.json repository.url is required to resolve VSP release assets');
|
|
@@ -36,12 +78,12 @@ export function releaseBaseUrl(pkg, version = pkg.version) {
|
|
|
36
78
|
const override = brandedEnvValue(process.env, 'RELEASE_BASE_URL');
|
|
37
79
|
if (override) return `${override.replace(/\/$/, '')}/v${version}`;
|
|
38
80
|
const repo = packageRepositoryUrl(pkg).replace(/^ssh:\/\/git@github.com:/, 'https://github.com/').replace(/^git@github.com:/, 'https://github.com/');
|
|
39
|
-
if (!repo.startsWith('
|
|
81
|
+
if (!repo.startsWith('https://')) throw new Error('package repository URL must be an HTTPS URL for release assets');
|
|
40
82
|
return `${repo}/releases/download/v${version}`;
|
|
41
83
|
}
|
|
42
84
|
|
|
43
|
-
export function cacheDirectory() {
|
|
44
|
-
return
|
|
85
|
+
export function cacheDirectory(env = process.env) {
|
|
86
|
+
return brandedEnvValue(env, 'CACHE_DIR') || join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'sap-ai-dev-toolkit');
|
|
45
87
|
}
|
|
46
88
|
|
|
47
89
|
export function cachedBinaryPath(pkg, platform = process.platform, arch = process.arch, env = process.env) {
|
|
@@ -51,22 +93,49 @@ export function cachedBinaryPath(pkg, platform = process.platform, arch = proces
|
|
|
51
93
|
|
|
52
94
|
function legacyCachedBinaryPath(pkg, platform, arch, env) {
|
|
53
95
|
const target = binaryTarget(platform, arch);
|
|
54
|
-
const
|
|
55
|
-
const cacheRoot = configuredCache || join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'bas-mcp-addon');
|
|
96
|
+
const cacheRoot = join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'bas-mcp-addon');
|
|
56
97
|
return join(cacheRoot, `bas-mcp-addon-${pkg.version}-${target.asset}`);
|
|
57
98
|
}
|
|
58
99
|
|
|
59
100
|
export async function findBinary(pkg, options = {}) {
|
|
60
101
|
const env = options.env || process.env;
|
|
102
|
+
const log = options.log || (() => {});
|
|
103
|
+
const target = binaryTarget(options.platform, options.arch);
|
|
61
104
|
if (brandedEnvValue(env, 'BINARY')) return brandedEnvValue(env, 'BINARY');
|
|
62
105
|
if (!brandedEnvValue(env, 'BINARY_URL')) {
|
|
63
106
|
const bundled = bundledBinaryPath(options.platform, options.arch);
|
|
64
|
-
try {
|
|
107
|
+
try {
|
|
108
|
+
await stat(bundled);
|
|
109
|
+
const verified = await verifyAgainstPackagedChecksums(bundled, target);
|
|
110
|
+
if (verified === false) {
|
|
111
|
+
log(`bundled VSP binary ${target.asset} failed checksum verification; ignoring it`);
|
|
112
|
+
} else {
|
|
113
|
+
if (verified === null) log(`bundled VSP binary ${target.asset} has no packaged checksum entry; using it unverified`);
|
|
114
|
+
return bundled;
|
|
115
|
+
}
|
|
116
|
+
} catch {}
|
|
65
117
|
}
|
|
66
118
|
const path = cachedBinaryPath(pkg, options.platform, options.arch, env);
|
|
67
|
-
try {
|
|
119
|
+
try {
|
|
120
|
+
await stat(path);
|
|
121
|
+
const verified = await verifyAgainstPackagedChecksums(path, target);
|
|
122
|
+
if (verified === false) {
|
|
123
|
+
log(`cached VSP binary ${target.asset} failed checksum verification; ignoring it`);
|
|
124
|
+
} else {
|
|
125
|
+
if (verified === null) log(`cached VSP binary ${target.asset} has no packaged checksum entry; using it unverified`);
|
|
126
|
+
return path;
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
// The legacy bas-mcp-addon cache was written by an older release this code
|
|
130
|
+
// never verified; only execute it when the packaged checksum confirms it.
|
|
68
131
|
const legacyPath = legacyCachedBinaryPath(pkg, options.platform, options.arch, env);
|
|
69
|
-
try {
|
|
132
|
+
try {
|
|
133
|
+
await stat(legacyPath);
|
|
134
|
+
const verified = await verifyAgainstPackagedChecksums(legacyPath, target);
|
|
135
|
+
if (verified === true) return legacyPath;
|
|
136
|
+
log(`legacy cached VSP binary ${legacyPath} has no packaged checksum match; ignoring it`);
|
|
137
|
+
} catch {}
|
|
138
|
+
return null;
|
|
70
139
|
}
|
|
71
140
|
|
|
72
141
|
export async function installBinary(pkg, options = {}) {
|
|
@@ -74,22 +143,37 @@ export async function installBinary(pkg, options = {}) {
|
|
|
74
143
|
if (brandedEnvValue(env, 'BINARY')) return brandedEnvValue(env, 'BINARY');
|
|
75
144
|
if (!brandedEnvValue(env, 'BINARY_URL')) {
|
|
76
145
|
const bundled = bundledBinaryPath(options.platform, options.arch);
|
|
77
|
-
try {
|
|
146
|
+
try {
|
|
147
|
+
await stat(bundled);
|
|
148
|
+
const target0 = binaryTarget(options.platform, options.arch);
|
|
149
|
+
if (await verifyAgainstPackagedChecksums(bundled, target0) !== false) return bundled;
|
|
150
|
+
throw new Error(`bundled VSP binary ${target0.asset} failed checksum verification`);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (String(error?.message || '').includes('checksum verification')) throw error;
|
|
153
|
+
}
|
|
78
154
|
}
|
|
79
155
|
const target = binaryTarget(options.platform, options.arch);
|
|
80
156
|
const base = releaseBaseUrl(pkg, pkg.version);
|
|
81
157
|
const assetUrl = brandedEnvValue(env, 'BINARY_URL') || `${base}/${target.asset}`;
|
|
82
|
-
const checksumUrl = `${base}/checksums.txt`;
|
|
83
158
|
const response = await fetch(assetUrl);
|
|
84
159
|
if (!response.ok) throw new Error(`VSP binary download failed (${response.status}); set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
85
160
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
86
|
-
const checksumResponse = await fetch(checksumUrl);
|
|
87
|
-
if (!checksumResponse.ok) throw new Error(`VSP checksum download failed (${checksumResponse.status}); set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
88
|
-
const checksumText = await checksumResponse.text();
|
|
89
|
-
const expected = checksumText.split(/\r?\n/).map(line => line.trim().split(/\s+/)).find(parts => parts.length >= 2 && (parts[1] === target.asset || parts[1].endsWith(`/${target.asset}`)))?.[0];
|
|
90
|
-
if (!expected) throw new Error(`checksums.txt has no entry for ${target.asset}; set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
91
161
|
const actual = createHash('sha256').update(bytes).digest('hex');
|
|
92
|
-
|
|
162
|
+
// Prefer the packaged checksum manifest (immutable per published version)
|
|
163
|
+
// as the anchor; only when it has no entry fall back to the release's own
|
|
164
|
+
// checksums.txt so pre-release builds keep working.
|
|
165
|
+
let expected = (await packagedChecksums()).get(target.asset);
|
|
166
|
+
let expectedSource = 'packaged checksums.txt';
|
|
167
|
+
if (!expected) {
|
|
168
|
+
const checksumUrl = `${base}/checksums.txt`;
|
|
169
|
+
const checksumResponse = await fetch(checksumUrl);
|
|
170
|
+
if (!checksumResponse.ok) throw new Error(`VSP checksum download failed (${checksumResponse.status}); set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
171
|
+
const checksumText = await checksumResponse.text();
|
|
172
|
+
expected = checksumText.split(/\r?\n/).map(line => line.trim().split(/\s+/)).find(parts => parts.length >= 2 && (parts[1] === target.asset || parts[1].endsWith(`/${target.asset}`)))?.[0]?.toLowerCase();
|
|
173
|
+
expectedSource = 'release checksums.txt';
|
|
174
|
+
if (!expected) throw new Error(`checksums.txt has no entry for ${target.asset}; set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
175
|
+
}
|
|
176
|
+
if (actual !== expected) throw new Error(`VSP checksum mismatch for ${target.asset} (verified against ${expectedSource})`);
|
|
93
177
|
const dir = cacheDirectory(env);
|
|
94
178
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
95
179
|
await chmod(dir, 0o700);
|
package/src/cf-destination.mjs
CHANGED
|
@@ -114,6 +114,51 @@ export async function deleteManagedCloudFoundryServiceKeys({ env = process.env,
|
|
|
114
114
|
return { warnings };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
async function listToolkitServiceKeys({ env = process.env, spaceGuid, execFileImpl = nodeExecFile } = {}) {
|
|
118
|
+
const options = { env, execFileImpl };
|
|
119
|
+
const keys = [];
|
|
120
|
+
let path = `/v3/service_credential_instances?space_guids=${encodeURIComponent(spaceGuid)}&type=key&per_page=500`;
|
|
121
|
+
const visited = new Set();
|
|
122
|
+
while (path && !visited.has(path)) {
|
|
123
|
+
visited.add(path);
|
|
124
|
+
const page = await cfJson(path, options);
|
|
125
|
+
if (!Array.isArray(page?.resources)) return keys;
|
|
126
|
+
for (const resource of page.resources) {
|
|
127
|
+
const name = text(resource?.name);
|
|
128
|
+
const instanceGuid = resource?.relationships?.service_instance?.data?.guid;
|
|
129
|
+
if (!name.startsWith(KEY_PREFIX) || !instanceGuid) continue;
|
|
130
|
+
keys.push({ keyName: name, instanceGuid: String(instanceGuid) });
|
|
131
|
+
}
|
|
132
|
+
path = page?.metadata?.pagination?.next?.href ? apiPath(page.metadata.pagination.next.href) : '';
|
|
133
|
+
}
|
|
134
|
+
return keys;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Reconcile toolkit-created service keys against the references recorded in
|
|
138
|
+
// the MCP config. A Destination-instance service key exposes client
|
|
139
|
+
// credentials for every destination in the instance, so a key orphaned by an
|
|
140
|
+
// interrupted setup must not linger unnoticed.
|
|
141
|
+
export async function findOrphanedCloudFoundryServiceKeys({ env = process.env, spaceGuid, managedKeys = [], execFileImpl = nodeExecFile } = {}) {
|
|
142
|
+
const listed = await listToolkitServiceKeys({ env, spaceGuid, execFileImpl });
|
|
143
|
+
const referenced = new Set((managedKeys || [])
|
|
144
|
+
.filter(ref => ref?.spaceGuid === spaceGuid && ref?.instanceGuid && ref?.keyName)
|
|
145
|
+
.map(ref => `${ref.instanceGuid}\0${ref.keyName}`));
|
|
146
|
+
const orphans = [];
|
|
147
|
+
const instanceNames = new Map();
|
|
148
|
+
for (const key of listed) {
|
|
149
|
+
if (referenced.has(`${key.instanceGuid}\0${key.keyName}`)) continue;
|
|
150
|
+
if (!instanceNames.has(key.instanceGuid)) {
|
|
151
|
+
try {
|
|
152
|
+
instanceNames.set(key.instanceGuid, text((await cfJson(`/v3/service_instances/${encodeURIComponent(key.instanceGuid)}`, { env, execFileImpl })).name));
|
|
153
|
+
} catch {
|
|
154
|
+
instanceNames.set(key.instanceGuid, '');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
orphans.push({ kind: 'toolkit-created', spaceGuid, instanceGuid: key.instanceGuid, instanceName: instanceNames.get(key.instanceGuid) || key.instanceGuid, keyName: key.keyName });
|
|
158
|
+
}
|
|
159
|
+
return orphans;
|
|
160
|
+
}
|
|
161
|
+
|
|
117
162
|
async function cfJson(path, options) {
|
|
118
163
|
const value = await runCf(['curl', path], options);
|
|
119
164
|
try {
|
|
@@ -493,10 +538,19 @@ function probeResult(status) {
|
|
|
493
538
|
return { status: 'unavailable', available: false, httpStatus: status };
|
|
494
539
|
}
|
|
495
540
|
|
|
541
|
+
function isLoopbackHostname(hostname) {
|
|
542
|
+
const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
543
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
544
|
+
}
|
|
545
|
+
|
|
496
546
|
async function probeRecord(record, env, { forcedProxy, fetchImpl = undiciFetch } = {}) {
|
|
497
547
|
const target = new URL('/sap/bc/adt/discovery', record.url);
|
|
498
548
|
const headers = { accept: 'application/xml,text/xml,*/*' };
|
|
499
|
-
|
|
549
|
+
// Never send SAP Basic credentials over cleartext HTTP to a remote host:
|
|
550
|
+
// an http:// destination URL is allowed for reachability probing, but the
|
|
551
|
+
// probe runs unauthenticated then (401/403 still counts as reachable).
|
|
552
|
+
const plaintextRemote = target.protocol === 'http:' && !isLoopbackHostname(target.hostname);
|
|
553
|
+
if (record.authentication === 'BasicAuthentication' && !plaintextRemote) {
|
|
500
554
|
headers.authorization = `Basic ${Buffer.from(`${record.user}:${record.password}`).toString('base64')}`;
|
|
501
555
|
}
|
|
502
556
|
let dispatcher;
|
|
@@ -10,8 +10,6 @@ import { brandedEnvValue } from './branding.mjs';
|
|
|
10
10
|
const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
|
|
11
11
|
const CREDENTIAL_MODES = new Set(['direct', 'bas-tunnel', 'cf-connectivity']);
|
|
12
12
|
|
|
13
|
-
const SECRET_PATTERN = /(authorization|cookie|password|secret|token|bearer|credential)/i;
|
|
14
|
-
|
|
15
13
|
async function exists(path) {
|
|
16
14
|
try {
|
|
17
15
|
await stat(path);
|
|
@@ -22,10 +20,6 @@ async function exists(path) {
|
|
|
22
20
|
}
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
export function redactCredentialValue(value) {
|
|
26
|
-
return SECRET_PATTERN.test(String(value)) ? '[redacted]' : value;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
23
|
export async function resolveCredentialsPath(env = process.env, mcpConfigPath) {
|
|
30
24
|
const configured = brandedEnvValue(env, 'CREDENTIALS_FILE');
|
|
31
25
|
if (configured) return configured;
|
|
@@ -117,7 +111,3 @@ export async function removeDestinationCredentials(path, names) {
|
|
|
117
111
|
if (removed.length) await writeCredentials(path, destinations);
|
|
118
112
|
return { path, removed };
|
|
119
113
|
}
|
|
120
|
-
|
|
121
|
-
export function listCredentialDestinations(credentials) {
|
|
122
|
-
return Object.entries(credentials?.destinations || {}).map(([name, entry]) => ({ name, host: entry.host, user: entry.user, updatedAt: entry.updatedAt }));
|
|
123
|
-
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
3
3
|
import { sanitizeChildEnv } from './bas-discovery.mjs';
|
|
4
|
+
import { redactText } from './redact.mjs';
|
|
4
5
|
|
|
5
6
|
const CHANGESET_WRITE_TOOLS = new Set(['WriteSource']);
|
|
6
7
|
const TRANSPORT_CHECK_TOOLS = new Set(['GetTransport', 'GetTransportInfo', 'ListDependencies', 'GetInactiveObjects', 'RunUnitTests', 'RunATCCheck']);
|
|
7
8
|
const MAX_CHANGE_BYTES = 128 * 1024;
|
|
8
9
|
const MAX_CHANGESET_SIZE = 12;
|
|
9
10
|
const MAX_REMOTE_BODY_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
const MAX_SUITE_CASES = 30;
|
|
10
12
|
|
|
11
13
|
function object(value) {
|
|
12
14
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -21,9 +23,7 @@ function toolJson(value) {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
function safeDiagnostic(value) {
|
|
24
|
-
return
|
|
25
|
-
.replace(/(authorization|proxy-authorization)\s*[:=]\s*(?:bearer\s+|basic\s+)?[^\s,;]+/gi, '$1=[redacted]')
|
|
26
|
-
.replace(/(cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
26
|
+
return redactText(value?.message || value || 'unknown error')
|
|
27
27
|
.replace(/\s+/g, ' ')
|
|
28
28
|
.slice(0, 300);
|
|
29
29
|
}
|
|
@@ -339,27 +339,43 @@ function responseForDemo(path) {
|
|
|
339
339
|
return { status: 200, contentType: 'application/json', body: JSON.stringify({ value: [{ ID: '10000001', Description: 'Sample order' }] }) };
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
function isLoopbackHost(hostname) {
|
|
343
|
+
const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
344
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// OData reads follow the same egress rules as the relay: loopback targets
|
|
348
|
+
// (the local BAS relay the child talks to) must go direct, NO_PROXY
|
|
349
|
+
// exemptions are honored, and everything else uses the configured proxy.
|
|
350
|
+
function proxyUrlFor(target, env) {
|
|
351
|
+
if (isLoopbackHost(target.hostname)) return '';
|
|
352
|
+
const host = target.hostname.toLowerCase();
|
|
353
|
+
const noProxy = String(env.NO_PROXY || env.no_proxy || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
|
|
354
|
+
if (noProxy.some(entry => entry === '*' || host === entry || host.endsWith(entry.startsWith('.') ? entry : `.${entry}`))) return '';
|
|
355
|
+
return String(env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy || '');
|
|
356
|
+
}
|
|
357
|
+
|
|
342
358
|
async function fetchOData(entry, path, accept, env) {
|
|
343
359
|
if (entry.destination.demo) return responseForDemo(path);
|
|
344
360
|
const childEnv = { ...sanitizeChildEnv(env), ...(entry.destination.childEnv || {}) };
|
|
345
|
-
const proxyUrl = childEnv.HTTP_PROXY || childEnv.http_proxy;
|
|
346
|
-
if (!proxyUrl) throw new Error('No HTTP proxy is configured for the destination');
|
|
347
361
|
const target = new URL(path, entry.destination.url);
|
|
348
362
|
const base = new URL(entry.destination.url);
|
|
349
363
|
if (target.origin !== base.origin || target.username || target.password || target.hash) throw new Error('OData request escaped the connected destination');
|
|
350
|
-
const
|
|
364
|
+
const proxyUrl = proxyUrlFor(target, childEnv);
|
|
365
|
+
if (!proxyUrl && /\.dest$/i.test(target.hostname)) throw new Error('No HTTP proxy is configured for the .dest destination');
|
|
366
|
+
const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : undefined;
|
|
351
367
|
try {
|
|
352
368
|
const response = await undiciFetch(target, {
|
|
353
369
|
method: 'GET',
|
|
354
370
|
headers: { accept },
|
|
355
|
-
dispatcher,
|
|
371
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
356
372
|
signal: AbortSignal.timeout(10_000),
|
|
357
373
|
redirect: 'manual'
|
|
358
374
|
});
|
|
359
375
|
const body = await readBody(response);
|
|
360
376
|
return { status: response.status, contentType: response.headers.get('content-type') || '', body };
|
|
361
377
|
} finally {
|
|
362
|
-
await dispatcher
|
|
378
|
+
await dispatcher?.close();
|
|
363
379
|
}
|
|
364
380
|
}
|
|
365
381
|
|
|
@@ -576,17 +592,23 @@ export function createEngineeringTools(entry, upstreamTools, { env = process.env
|
|
|
576
592
|
if (response.status < 200 || response.status >= 300) throw new Error(`OData metadata request returned HTTP ${response.status}`);
|
|
577
593
|
const entitySets = parseEntitySets(response.body);
|
|
578
594
|
if (!entitySets.length) throw new Error('OData metadata did not contain entity sets');
|
|
595
|
+
// Cap generation so the emitted suite is always accepted by
|
|
596
|
+
// RunRAPRegressionSuite's own 30-case limit; report what was omitted.
|
|
597
|
+
const included = entitySets.slice(0, MAX_SUITE_CASES - 1);
|
|
579
598
|
return toolJson({
|
|
580
599
|
suite: {
|
|
581
600
|
name: `${entry.destination.name} RAP smoke suite`,
|
|
582
601
|
service_root: serviceRoot,
|
|
583
602
|
cases: [
|
|
584
603
|
{ name: 'OData metadata', path: '$metadata', expected_status: 200, expected_content_type: 'xml' },
|
|
585
|
-
...
|
|
604
|
+
...included.map(name => ({ name: `Read ${name}`, path: `${name}?$top=1`, expected_status: 200, expected_content_type: 'json' }))
|
|
586
605
|
]
|
|
587
606
|
},
|
|
588
607
|
entity_set_count: entitySets.length,
|
|
589
|
-
destination: entry.destination.name
|
|
608
|
+
destination: entry.destination.name,
|
|
609
|
+
...(entitySets.length > included.length
|
|
610
|
+
? { note: `${entitySets.length - included.length} entity sets were omitted to keep the suite within the ${MAX_SUITE_CASES}-case limit; generate targeted suites for them separately.` }
|
|
611
|
+
: {})
|
|
590
612
|
});
|
|
591
613
|
});
|
|
592
614
|
|
package/src/hana-tools.mjs
CHANGED
|
@@ -7,7 +7,11 @@ const MAX_COLUMNS = 40;
|
|
|
7
7
|
const MAX_FILTERS = 20;
|
|
8
8
|
const MAX_SELECTED_COLUMN_WIDTH = 8192;
|
|
9
9
|
const UNBOUNDED_COLUMN_TYPES = new Set(['BLOB', 'CLOB', 'NCLOB', 'BINARY', 'VARBINARY', 'ST_GEOMETRY', 'ST_POINT']);
|
|
10
|
-
|
|
10
|
+
// Denylist of credential-like column names. This is a heuristic — the real
|
|
11
|
+
// control is the dedicated read-only HANA identity — but it keeps obvious
|
|
12
|
+
// secret material (password hashes, salts, signatures, certificates, JWTs,
|
|
13
|
+
// session ids) out of tool results even under that identity.
|
|
14
|
+
const SENSITIVE_COLUMN_NAME = /(?:PASS(?:WORD|WD)?|PWD|PASSPHRASE|SECRET|CREDENTIAL|(?:ACCESS|REFRESH|AUTH|SESSION)?_?TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|SIGNATURE|SALT|CERT(?:IFICATE)?|JWT|BEARER)/i;
|
|
11
15
|
const SAFE_INSPECTION_ERRORS = new Set([
|
|
12
16
|
'HANA result is too large to return safely; request fewer rows or columns.',
|
|
13
17
|
'The requested object was not found in the bound HDI schema.',
|
|
@@ -181,7 +185,10 @@ async function readRows(database, schema, args) {
|
|
|
181
185
|
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
182
186
|
const sql = `SELECT ${selected} FROM ${quoteHanaIdentifier(schema)}.${quoteHanaIdentifier(args.objectName)}${where}${order} LIMIT ${args.limit}`;
|
|
183
187
|
const values = args.filters.map(filter => filter.value);
|
|
184
|
-
|
|
188
|
+
// Defense in depth: the SQL LIMIT and the zod cap already bound the read,
|
|
189
|
+
// but the returned rows are sliced again so no future query-shape change
|
|
190
|
+
// can smuggle more than HANA_MAX_ROWS into a tool result.
|
|
191
|
+
const rows = rowsOf(await database.query(sql, values)).slice(0, HANA_MAX_ROWS);
|
|
185
192
|
return {
|
|
186
193
|
schema,
|
|
187
194
|
...target,
|
package/src/launcher.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { spawn } from 'node:child_process';
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { discoverDestinations, remediation, statusRows } from './bas-discovery.mjs';
|
|
6
|
+
import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
|
|
7
7
|
import { findBinary } from './binary.mjs';
|
|
8
8
|
import { MCPProxy } from './mcp-proxy.mjs';
|
|
9
9
|
import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
@@ -11,12 +11,16 @@ import { runSetup } from './setup.mjs';
|
|
|
11
11
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
12
12
|
import { withBrandedEnvironment } from './branding.mjs';
|
|
13
13
|
import { enrichWithStoredCredentials } from './credential-overrides.mjs';
|
|
14
|
+
import { redactText } from './redact.mjs';
|
|
14
15
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
15
16
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
16
17
|
const runtimeEnv = withBrandedEnvironment(process.env);
|
|
17
18
|
|
|
18
19
|
function hasFlag(name) { return process.argv.slice(2).includes(name); }
|
|
19
20
|
function json(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
|
|
21
|
+
// Timestamped stderr lines so MCP server output shows when each event
|
|
22
|
+
// happened, not just that it did.
|
|
23
|
+
function logLine(message) { console.error(`[${new Date().toISOString()}] ${message}`); }
|
|
20
24
|
|
|
21
25
|
function usage() {
|
|
22
26
|
return [
|
|
@@ -44,10 +48,6 @@ function doctorRow(name, stage, status, detail) {
|
|
|
44
48
|
return { name, stage, status, ...(detail ? { detail } : {}) };
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
function redactText(value) {
|
|
48
|
-
return String(value).replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
51
|
async function runDoctor(destinations) {
|
|
52
52
|
const checks = [];
|
|
53
53
|
if (!destinations.length) {
|
|
@@ -108,10 +108,11 @@ async function runDoctor(destinations) {
|
|
|
108
108
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
109
109
|
if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
|
|
110
110
|
const tools = listed.result?.tools || [];
|
|
111
|
-
const
|
|
112
|
-
const
|
|
111
|
+
const toolPrefix = `${slugifyDestination(destination.name)}_`;
|
|
112
|
+
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']);
|
|
113
|
+
const upstreamCount = tools.filter(tool => tool.name.startsWith(toolPrefix) && !localSegments.has(tool.name.slice(toolPrefix.length))).length;
|
|
113
114
|
checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} VSP tools and ${tools.length} total MCP tools returned; chat-picker binding is host-managed`));
|
|
114
|
-
const systemInfo = tools.find(tool => tool.name
|
|
115
|
+
const systemInfo = tools.find(tool => tool.name === `${toolPrefix}get_system_info`);
|
|
115
116
|
if (!systemInfo) {
|
|
116
117
|
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
|
|
117
118
|
} else {
|
|
@@ -120,7 +121,7 @@ async function runDoctor(destinations) {
|
|
|
120
121
|
let session;
|
|
121
122
|
let preflightError;
|
|
122
123
|
try {
|
|
123
|
-
session = await relayedDestination.relay.probeCsrfSession(
|
|
124
|
+
session = await relayedDestination.relay.probeCsrfSession();
|
|
124
125
|
} catch (error) {
|
|
125
126
|
preflightError = redactText(error.message || error).slice(0, 250);
|
|
126
127
|
}
|
|
@@ -163,7 +164,8 @@ async function runOfflineDemo() {
|
|
|
163
164
|
childArgs: () => [demoPath],
|
|
164
165
|
destinations: [{ name: 'demo', url: 'http://demo.dest', client: '001', demo: true }],
|
|
165
166
|
env: { ...runtimeEnv, SAP_AI_DEV_TOOLKIT_DESTINATION: 'demo' },
|
|
166
|
-
log: message => console.error(message)
|
|
167
|
+
log: message => console.error(message),
|
|
168
|
+
version: pkg.version
|
|
167
169
|
});
|
|
168
170
|
console.error('[sap-ai-dev-toolkit] offline demo active; all SAP changes are simulated in memory');
|
|
169
171
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
@@ -177,8 +179,7 @@ function probeDiagnostic(destination) {
|
|
|
177
179
|
const details = [];
|
|
178
180
|
if (probe.httpStatus) details.push(`HTTP ${probe.httpStatus}`);
|
|
179
181
|
if (probe.error) {
|
|
180
|
-
const error =
|
|
181
|
-
.replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
182
|
+
const error = redactText(probe.error)
|
|
182
183
|
.replace(/\s+/g, ' ')
|
|
183
184
|
.slice(0, 400);
|
|
184
185
|
details.push(error);
|
|
@@ -258,7 +259,7 @@ async function main() {
|
|
|
258
259
|
let proxy;
|
|
259
260
|
try {
|
|
260
261
|
const binary = await binaryOrError();
|
|
261
|
-
proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log:
|
|
262
|
+
proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine, version: pkg.version });
|
|
262
263
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
263
264
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
264
265
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
@@ -283,19 +284,21 @@ async function main() {
|
|
|
283
284
|
}
|
|
284
285
|
|
|
285
286
|
const discovered = await discoverDestinations({ env: runtimeEnv });
|
|
286
|
-
for (const destination of discovered)
|
|
287
|
+
for (const destination of discovered) logLine(probeDiagnostic(destination));
|
|
287
288
|
const destinations = await enrichWithStoredCredentials(discovered, runtimeEnv);
|
|
288
289
|
if (!destinations.length) {
|
|
289
|
-
|
|
290
|
+
logLine('[sap-ai-dev] destination discovery returned no named BAS destinations');
|
|
290
291
|
throw new Error(remediation);
|
|
291
292
|
}
|
|
292
293
|
const binary = await binaryOrError();
|
|
294
|
+
logLine(`[sap-ai-dev] sap-ai-dev-toolkit v${pkg.version} (node ${process.version}, pid ${process.pid})`);
|
|
295
|
+
logLine(`[sap-ai-dev] VSP binary: ${binary}`);
|
|
293
296
|
for (const destination of destinations) {
|
|
294
|
-
if (destination.credentials?.mode === 'bas-tunnel')
|
|
295
|
-
else if (destination.credentials?.host)
|
|
297
|
+
if (destination.credentials?.mode === 'bas-tunnel') logLine(`[sap-ai-dev] ${destination.name}: Basic credentials enabled through the BAS proxy tunnel`);
|
|
298
|
+
else if (destination.credentials?.host) logLine(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
|
|
296
299
|
}
|
|
297
|
-
|
|
298
|
-
const proxy = new MCPProxy({ binary, destinations, env: process.env, log:
|
|
300
|
+
logLine(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
|
|
301
|
+
const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine, version: pkg.version });
|
|
299
302
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
300
303
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
301
304
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
package/src/mcp-config.mjs
CHANGED
|
@@ -3,10 +3,27 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { brandedEnvValue } from './branding.mjs';
|
|
8
|
+
import { slugifyDestination } from './bas-discovery.mjs';
|
|
7
9
|
|
|
8
10
|
const LEGACY_MCP_SERVER_PREFIXES = ['sapAiDev_', 'basVspMcp_'];
|
|
9
11
|
const COMPANION_SERVER_KIND = 'sap-development-companion';
|
|
12
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
13
|
+
|
|
14
|
+
// The self-package companion (HANA inspector) must launch the exact version
|
|
15
|
+
// the user installed; an unpinned `npx sap-ai-dev-toolkit` would re-resolve
|
|
16
|
+
// executable code from the registry at every MCP host start.
|
|
17
|
+
let selfPackageVersion;
|
|
18
|
+
async function runningPackageVersion() {
|
|
19
|
+
if (selfPackageVersion !== undefined) return selfPackageVersion;
|
|
20
|
+
try {
|
|
21
|
+
selfPackageVersion = String(JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version || '');
|
|
22
|
+
} catch {
|
|
23
|
+
selfPackageVersion = '';
|
|
24
|
+
}
|
|
25
|
+
return selfPackageVersion;
|
|
26
|
+
}
|
|
10
27
|
|
|
11
28
|
export const SAP_DEVELOPMENT_MCP_SERVERS = Object.freeze([
|
|
12
29
|
Object.freeze({
|
|
@@ -95,8 +112,10 @@ export async function resolveMcpConfigPath(env = process.env) {
|
|
|
95
112
|
return candidates[0];
|
|
96
113
|
}
|
|
97
114
|
|
|
115
|
+
// Server entry names are lowercase slugs: VS Code/BAS derive chat tool
|
|
116
|
+
// references from the server name and only bind lowercase identifiers.
|
|
98
117
|
export function generatedServerName(destinationName) {
|
|
99
|
-
return
|
|
118
|
+
return slugifyDestination(destinationName);
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SERVERS.map(server => server.id), { packageVersions = {}, packageManager = 'npx' } = {}) {
|
|
@@ -128,7 +147,7 @@ export function buildMcpEntries(destinations, env = process.env) {
|
|
|
128
147
|
for (const destination of selected) {
|
|
129
148
|
const isCf = destination.source === 'cloud-foundry';
|
|
130
149
|
const name = generatedServerName(isCf ? destination.serverName : destination.name);
|
|
131
|
-
if (Object.hasOwn(entries, name)) throw new Error(`Duplicate MCP destination server name: ${name}`);
|
|
150
|
+
if (Object.hasOwn(entries, name)) throw new Error(`Duplicate MCP destination server name: ${name} (two destination names normalize to the same lowercase server name)`);
|
|
132
151
|
const entryEnv = {
|
|
133
152
|
H2O_URL: String(h2oUrl),
|
|
134
153
|
SAP_ALLOW_TRANSPORTABLE_EDITS: 'true'
|
|
@@ -291,9 +310,11 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
291
310
|
const env = options.env || process.env;
|
|
292
311
|
const path = options.path || await resolveMcpConfigPath(env);
|
|
293
312
|
const config = await readConfig(path);
|
|
313
|
+
const selfVersion = await runningPackageVersion();
|
|
314
|
+
const companionVersions = selfVersion ? { 'sap-ai-dev-toolkit': selfVersion, 'hana-cloud-inspector': selfVersion } : {};
|
|
294
315
|
const generated = {
|
|
295
316
|
...buildMcpEntries(destinations, env),
|
|
296
|
-
...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [])
|
|
317
|
+
...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [], { packageVersions: companionVersions })
|
|
297
318
|
};
|
|
298
319
|
const launcherCommand = options.command || (destinations.length ? await resolveMcpServerCommand(env) : null);
|
|
299
320
|
if (launcherCommand) {
|
|
@@ -339,10 +360,18 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
|
|
|
339
360
|
for (const [serverName, entry] of Object.entries(config.servers)) {
|
|
340
361
|
if (!isManagedBasDestinationEntry(entry)) continue;
|
|
341
362
|
const destinationName = destinationValue(entry.env);
|
|
342
|
-
|
|
363
|
+
const expectedName = generatedServerName(destinationName);
|
|
343
364
|
const destination = basDestinations.get(destinationName);
|
|
344
365
|
if (!destination) continue;
|
|
345
|
-
|
|
366
|
+
if (expectedName !== serverName) {
|
|
367
|
+
// Legacy mixed-case entries migrate to the normalized lowercase key.
|
|
368
|
+
// A user-owned entry on the target key blocks the rename; merging is
|
|
369
|
+
// only safe onto another managed entry for the same destination.
|
|
370
|
+
const target = config.servers[expectedName];
|
|
371
|
+
const targetMergable = isManagedBasDestinationEntry(target) && destinationValue(target.env) === destinationName;
|
|
372
|
+
if (Object.hasOwn(config.servers, expectedName) && !targetMergable) continue;
|
|
373
|
+
}
|
|
374
|
+
const expected = buildMcpEntries([destination], env)[expectedName];
|
|
346
375
|
const nextEnvironment = { ...entry.env, ...expected.env };
|
|
347
376
|
if (Object.hasOwn(entry.env, 'SAP_ALLOW_TRANSPORTABLE_EDITS')) {
|
|
348
377
|
nextEnvironment.SAP_ALLOW_TRANSPORTABLE_EDITS = entry.env.SAP_ALLOW_TRANSPORTABLE_EDITS;
|
|
@@ -359,8 +388,13 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
|
|
|
359
388
|
} else {
|
|
360
389
|
nextEntry.command = localCommand;
|
|
361
390
|
}
|
|
362
|
-
if (
|
|
363
|
-
|
|
391
|
+
if (expectedName === serverName) {
|
|
392
|
+
if (JSON.stringify(nextEntry) === JSON.stringify(entry)) continue;
|
|
393
|
+
config.servers[serverName] = nextEntry;
|
|
394
|
+
} else {
|
|
395
|
+
delete config.servers[serverName];
|
|
396
|
+
config.servers[expectedName] = nextEntry;
|
|
397
|
+
}
|
|
364
398
|
repaired += 1;
|
|
365
399
|
}
|
|
366
400
|
|