sap-ai-dev-toolkit 0.5.5 → 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 +6 -6
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +14 -16
- package/src/bas-destination-relay.mjs +25 -3
- package/src/bas-discovery.mjs +9 -3
- 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 +8 -11
- package/src/mcp-config.mjs +19 -1
- package/src/mcp-proxy.mjs +230 -76
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +30 -1
- package/tools.md +1 -1
- package/test/bas-destination-relay.test.mjs +0 -415
- 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 -282
- package/test/fixtures/fake-vsp.mjs +0 -266
- 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 -401
- package/test/live-s4h.test.mjs +0 -204
- package/test/mcp-config-cf.test.mjs +0 -475
- package/test/mcp-proxy.test.mjs +0 -604
- 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
|
@@ -1,475 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import { buildMcpEntries, buildSapDevelopmentMcpEntries, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, generatedServerName, installMcpConfig, readMcpConfig, repairManagedMcpConfig, resolveMcpConfigPath } from '../src/mcp-config.mjs';
|
|
7
|
-
|
|
8
|
-
const bas = {
|
|
9
|
-
source: 'bas', name: 'shared', serverName: 'shared', url: 'http://shared.dest',
|
|
10
|
-
client: '001', authentication: 'NoAuthentication', probe: { status: 'available', available: true }
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
function cfDestination(spaceGuid, destinationInstanceGuid, keyName, proxyType = 'Internet') {
|
|
14
|
-
return {
|
|
15
|
-
source: 'cloud-foundry',
|
|
16
|
-
name: 'shared',
|
|
17
|
-
serverName: `cf:${spaceGuid}:${destinationInstanceGuid}:shared`,
|
|
18
|
-
client: '100',
|
|
19
|
-
authentication: 'BasicAuthentication',
|
|
20
|
-
proxyType,
|
|
21
|
-
probe: { status: 'auth-required', available: true },
|
|
22
|
-
cf: {
|
|
23
|
-
spaceGuid,
|
|
24
|
-
destinationInstanceGuid,
|
|
25
|
-
destinationInstanceName: `destination-${destinationInstanceGuid}`,
|
|
26
|
-
destinationKeyName: keyName,
|
|
27
|
-
...(proxyType === 'OnPremise' ? {
|
|
28
|
-
connectivityInstanceGuid: 'connectivity-guid',
|
|
29
|
-
connectivityInstanceName: 'connectivity-service',
|
|
30
|
-
connectivityKeyName: 'connectivity-key'
|
|
31
|
-
} : {})
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
test('writes source-qualified CF entries beside same-named BAS destinations', () => {
|
|
37
|
-
const first = cfDestination('space-one', 'instance-one', 'key-one');
|
|
38
|
-
const second = cfDestination('space-one', 'instance-two', 'key-two');
|
|
39
|
-
const entries = buildMcpEntries([bas, first, second], { H2O_URL: 'http://h2o.example' });
|
|
40
|
-
assert.deepEqual(Object.keys(entries).sort(), [
|
|
41
|
-
'cf-space-one-instance-one-shared',
|
|
42
|
-
'cf-space-one-instance-two-shared',
|
|
43
|
-
'shared'
|
|
44
|
-
]);
|
|
45
|
-
assert.deepEqual(entries['cf-space-one-instance-one-shared'].env, {
|
|
46
|
-
H2O_URL: 'http://h2o.example',
|
|
47
|
-
SAP_ALLOW_TRANSPORTABLE_EDITS: 'true',
|
|
48
|
-
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
49
|
-
SAP_AI_DEV_TOOLKIT_DESTINATION: 'shared',
|
|
50
|
-
BAS_CF_SPACE_GUID: 'space-one',
|
|
51
|
-
BAS_CF_DESTINATION_INSTANCE_GUID: 'instance-one',
|
|
52
|
-
BAS_CF_DESTINATION_INSTANCE: 'destination-instance-one',
|
|
53
|
-
BAS_CF_DESTINATION_KEY: 'key-one',
|
|
54
|
-
BAS_CF_DESTINATION_NAME: 'shared'
|
|
55
|
-
});
|
|
56
|
-
assert.equal(entries.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
|
|
57
|
-
assert.equal(JSON.stringify(entries).includes('shared.dest'), false);
|
|
58
|
-
assert.equal(JSON.stringify(entries).includes('Password'), false);
|
|
59
|
-
|
|
60
|
-
const onPremise = buildMcpEntries([cfDestination('space-one', 'onprem-instance', 'destination-key', 'OnPremise')], { H2O_URL: 'http://h2o.example' });
|
|
61
|
-
assert.equal(onPremise['cf-space-one-onprem-instance-shared'].env.BAS_CF_CONNECTIVITY_KEY, 'connectivity-key');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('stores and reconciles the MCP launcher executable location', async t => {
|
|
65
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-launcher-location-'));
|
|
66
|
-
const path = join(directory, 'mcp.json');
|
|
67
|
-
const command = join(directory, 'sap-ai-dev');
|
|
68
|
-
await writeFile(command, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
69
|
-
await writeFile(path, JSON.stringify({ servers: { userServer: { command: 'custom-server' } } }));
|
|
70
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
71
|
-
|
|
72
|
-
const env = { H2O_URL: 'http://h2o.example', PATH: directory };
|
|
73
|
-
const installed = await installMcpConfig([bas], { env, path });
|
|
74
|
-
assert.equal(installed.servers.shared.command, command);
|
|
75
|
-
assert.equal((await readMcpConfig(path)).servers.shared.command, command);
|
|
76
|
-
|
|
77
|
-
await installMcpConfig([], { env, path });
|
|
78
|
-
assert.deepEqual(Object.keys((await readMcpConfig(path)).servers), ['userServer']);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('resolves documented, toolkit, and legacy MCP config paths in order', async () => {
|
|
82
|
-
assert.equal(await resolveMcpConfigPath({
|
|
83
|
-
SAP_AI_DEV_MCP_CONFIG: '/tmp/documented-mcp.json',
|
|
84
|
-
SAP_AI_DEV_TOOLKIT_MCP_CONFIG: '/tmp/toolkit-mcp.json',
|
|
85
|
-
BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json'
|
|
86
|
-
}), '/tmp/documented-mcp.json');
|
|
87
|
-
assert.equal(await resolveMcpConfigPath({
|
|
88
|
-
SAP_AI_DEV_TOOLKIT_MCP_CONFIG: '/tmp/toolkit-mcp.json',
|
|
89
|
-
BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json'
|
|
90
|
-
}), '/tmp/toolkit-mcp.json');
|
|
91
|
-
assert.equal(await resolveMcpConfigPath({ BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json' }), '/tmp/legacy-mcp.json');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test('doctor repair updates only discovered toolkit BAS entries and is idempotent', async t => {
|
|
95
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-'));
|
|
96
|
-
const path = join(directory, 'mcp.json');
|
|
97
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
98
|
-
const cf = cfDestination('space-one', 'instance-one', 'cf-key');
|
|
99
|
-
const strictDestination = { ...bas, name: 'strict', serverName: 'strict', url: 'http://strict.dest' };
|
|
100
|
-
const managed = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
|
|
101
|
-
const strictManaged = buildMcpEntries([strictDestination], { H2O_URL: 'http://old-h2o.example' }).strict;
|
|
102
|
-
const cfEntry = buildMcpEntries([cf], { H2O_URL: 'http://cf-h2o.example' })[generatedServerName(cf.serverName)];
|
|
103
|
-
const companion = buildSapDevelopmentMcpEntries(['sap-fiori-tools'])['sap-fiori-tools'];
|
|
104
|
-
const unrelated = { type: 'stdio', command: 'external-server' };
|
|
105
|
-
const unverifiedManaged = {
|
|
106
|
-
...managed,
|
|
107
|
-
env: { ...managed.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'temporarily-unavailable' }
|
|
108
|
-
};
|
|
109
|
-
const config = {
|
|
110
|
-
userFlag: true,
|
|
111
|
-
servers: {
|
|
112
|
-
shared: { ...managed, env: { ...managed.env, H2O_URL: 'http://old-h2o.example' } },
|
|
113
|
-
strict: { ...strictManaged, env: { ...strictManaged.env, SAP_ALLOW_TRANSPORTABLE_EDITS: 'false' } },
|
|
114
|
-
'temporarily-unavailable': unverifiedManaged,
|
|
115
|
-
[generatedServerName(cf.serverName)]: cfEntry,
|
|
116
|
-
'sap-fiori-tools': companion,
|
|
117
|
-
ActionS4D_100: unrelated
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
await writeFile(path, JSON.stringify(config, null, 2));
|
|
121
|
-
|
|
122
|
-
const first = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
123
|
-
assert.equal(first.changed, true);
|
|
124
|
-
assert.equal(first.repaired, 2);
|
|
125
|
-
const repaired = await readMcpConfig(path);
|
|
126
|
-
assert.equal(repaired.userFlag, true);
|
|
127
|
-
assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
|
|
128
|
-
assert.equal(repaired.servers.shared.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'true');
|
|
129
|
-
assert.equal(repaired.servers.strict.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'false', "doctor must preserve a user's restricted write setting");
|
|
130
|
-
assert.deepEqual(repaired.servers['temporarily-unavailable'], unverifiedManaged);
|
|
131
|
-
assert.deepEqual(repaired.servers[generatedServerName(cf.serverName)], cfEntry);
|
|
132
|
-
assert.deepEqual(repaired.servers['sap-fiori-tools'], companion);
|
|
133
|
-
assert.deepEqual(repaired.servers.ActionS4D_100, unrelated);
|
|
134
|
-
assert.equal(repaired.servers.newDestination, undefined, 'repair must not add unselected destinations');
|
|
135
|
-
|
|
136
|
-
const beforeSecondRepair = await readFile(path, 'utf8');
|
|
137
|
-
const second = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
138
|
-
assert.equal(second.changed, false);
|
|
139
|
-
assert.equal(second.repaired, 0);
|
|
140
|
-
assert.equal(await readFile(path, 'utf8'), beforeSecondRepair, 'an already-healed config must not be rewritten');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('doctor repair leaves malformed config untouched', async t => {
|
|
144
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-invalid-'));
|
|
145
|
-
const path = join(directory, 'mcp.json');
|
|
146
|
-
const malformed = '{ "servers": ';
|
|
147
|
-
await writeFile(path, malformed);
|
|
148
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
149
|
-
await assert.rejects(
|
|
150
|
-
() => repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example' }, path, discoveryComplete: true }),
|
|
151
|
-
/invalid JSON/
|
|
152
|
-
);
|
|
153
|
-
assert.equal(await readFile(path, 'utf8'), malformed);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test('doctor repair skips config changes if destination discovery is incomplete', async t => {
|
|
157
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-incomplete-'));
|
|
158
|
-
const path = join(directory, 'mcp.json');
|
|
159
|
-
const entry = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
|
|
160
|
-
const initial = JSON.stringify({ servers: { shared: entry } });
|
|
161
|
-
await writeFile(path, initial);
|
|
162
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
163
|
-
const result = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://new-h2o.example' }, path });
|
|
164
|
-
assert.equal(result.changed, false);
|
|
165
|
-
assert.match(result.skipped, /incomplete/);
|
|
166
|
-
assert.equal(await readFile(path, 'utf8'), initial);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('doctor repair refreshes stale toolkit launchers without changing npx behavior', async t => {
|
|
170
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-launcher-repair-'));
|
|
171
|
-
const path = join(directory, 'mcp.json');
|
|
172
|
-
const bin = join(directory, 'bin');
|
|
173
|
-
await import('node:fs/promises').then(({ mkdir }) => mkdir(bin));
|
|
174
|
-
const command = join(bin, 'sap-ai-dev');
|
|
175
|
-
await writeFile(command, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
176
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
177
|
-
const npx = {
|
|
178
|
-
...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared,
|
|
179
|
-
command: 'npx',
|
|
180
|
-
args: ['--yes', '--package=sap-ai-dev-toolkit@0.4.2', 'sap-ai-dev'],
|
|
181
|
-
env: {
|
|
182
|
-
...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared.env,
|
|
183
|
-
SAP_AI_DEV_TOOLKIT_DESTINATION: 'shared',
|
|
184
|
-
OPERATOR_SETTING: 'preserve'
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
const direct = {
|
|
188
|
-
...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared,
|
|
189
|
-
command: 'stale-sap-ai-dev'
|
|
190
|
-
};
|
|
191
|
-
await writeFile(path, JSON.stringify({ servers: { shared: npx, other: { ...direct, env: { ...direct.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'other' } }, userServer: { command: 'other' } } }));
|
|
192
|
-
|
|
193
|
-
const result = await repairManagedMcpConfig([bas], {
|
|
194
|
-
env: { H2O_URL: 'http://current-h2o.example', PATH: bin },
|
|
195
|
-
path,
|
|
196
|
-
discoveryComplete: true,
|
|
197
|
-
packageVersion: '0.4.5'
|
|
198
|
-
});
|
|
199
|
-
assert.equal(result.repaired, 1, 'only the entry that maps to the discovered destination is repaired');
|
|
200
|
-
const repaired = await readMcpConfig(path);
|
|
201
|
-
assert.equal(repaired.servers.shared.command, 'npx');
|
|
202
|
-
assert.deepEqual(repaired.servers.shared.args, ['--yes', '--package=sap-ai-dev-toolkit@0.4.5', 'sap-ai-dev']);
|
|
203
|
-
assert.equal(repaired.servers.shared.env.OPERATOR_SETTING, 'preserve');
|
|
204
|
-
assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
|
|
205
|
-
assert.equal(repaired.servers.other.command, 'stale-sap-ai-dev', 'unmatched toolkit entry must remain untouched');
|
|
206
|
-
assert.deepEqual(repaired.servers.userServer, { command: 'other' });
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test('extracts key references only from package-managed Cloud Foundry entries', () => {
|
|
210
|
-
const cloudFoundry = buildMcpEntries([
|
|
211
|
-
cfDestination('space-one', 'instance-one', 'destination-key', 'OnPremise')
|
|
212
|
-
], { H2O_URL: 'http://h2o.example' })['cf-space-one-instance-one-shared'];
|
|
213
|
-
const managedNpx = {
|
|
214
|
-
...cloudFoundry,
|
|
215
|
-
command: 'npx',
|
|
216
|
-
args: ['--yes', '--package=sap-ai-dev-toolkit@0.1.0', 'sap-ai-dev']
|
|
217
|
-
};
|
|
218
|
-
const config = { servers: {
|
|
219
|
-
first: cloudFoundry,
|
|
220
|
-
duplicate: managedNpx,
|
|
221
|
-
unmanaged: { ...cloudFoundry, BAS_EXT: undefined },
|
|
222
|
-
otherPackage: { ...cloudFoundry, command: 'other' },
|
|
223
|
-
otherSource: { ...cloudFoundry, env: { ...cloudFoundry.env, SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'bas' } }
|
|
224
|
-
} };
|
|
225
|
-
assert.deepEqual(collectManagedCloudFoundryKeyReferences(config), [
|
|
226
|
-
{ kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'destination-key' },
|
|
227
|
-
{ kind: 'connectivity', spaceGuid: 'space-one', instanceGuid: 'connectivity-guid', instanceName: 'connectivity-service', keyName: 'connectivity-key' }
|
|
228
|
-
]);
|
|
229
|
-
const userConfig = { servers: {
|
|
230
|
-
customServer: { command: 'custom-launcher', env: { ...cloudFoundry.env, SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: undefined } },
|
|
231
|
-
noEnvironment: { command: 'custom-launcher' }
|
|
232
|
-
} };
|
|
233
|
-
assert.deepEqual(collectCloudFoundryKeyReferencesFromAllEntries(userConfig), [
|
|
234
|
-
{ kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'destination-key' },
|
|
235
|
-
{ kind: 'connectivity', spaceGuid: 'space-one', instanceGuid: 'connectivity-guid', instanceName: 'connectivity-service', keyName: 'connectivity-key' }
|
|
236
|
-
]);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
test('builds SAP development companion MCP entries', () => {
|
|
240
|
-
const entries = buildSapDevelopmentMcpEntries(['sap-fiori-tools', 'ui5-tools']);
|
|
241
|
-
assert.deepEqual(Object.keys(entries), ['sap-fiori-tools', 'ui5-tools']);
|
|
242
|
-
assert.deepEqual(entries['sap-fiori-tools'].args, ['--yes', '--package=@sap-ux/fiori-mcp-server', 'fiori-mcp']);
|
|
243
|
-
assert.equal(entries['ui5-tools'].command, 'npx');
|
|
244
|
-
assert.equal(entries['ui5-tools'].BAS_EXT_KIND, 'sap-development-companion');
|
|
245
|
-
assert.throws(() => buildSapDevelopmentMcpEntries(['missing-tool']), /Unknown SAP development MCP server id/);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test('configures the HANA inspector without persisting credentials', () => {
|
|
249
|
-
const entry = buildSapDevelopmentMcpEntries(['hana-cloud-inspector'])['hana-cloud-inspector'];
|
|
250
|
-
assert.equal(entry.command, 'npx');
|
|
251
|
-
assert.deepEqual(entry.args, ['--yes', '--ignore-scripts', '--package=sap-ai-dev-toolkit', 'sap-ai-hana']);
|
|
252
|
-
assert.equal(entry.BAS_EXT_KIND, 'sap-development-companion');
|
|
253
|
-
assert.equal(entry.displayName, 'HANA Cloud inspector');
|
|
254
|
-
assert.equal(entry.env, undefined);
|
|
255
|
-
assert.doesNotMatch(JSON.stringify(entry), /"(?:HANA_RO_PASSWORD|VCAP_SERVICES)"\s*:/);
|
|
256
|
-
assert.doesNotMatch(JSON.stringify(entry), /"(?:password|pwd)"\s*:/i);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
test('installs and removes SAP development companion entries', async t => {
|
|
260
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-tools-config-'));
|
|
261
|
-
const path = join(directory, 'mcp.json');
|
|
262
|
-
await writeFile(path, JSON.stringify({ servers: { userServer: { type: 'stdio', command: 'custom-server' } } }));
|
|
263
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
264
|
-
|
|
265
|
-
const result = await installMcpConfig([], {
|
|
266
|
-
env: { H2O_URL: 'http://h2o.example' },
|
|
267
|
-
path,
|
|
268
|
-
sapDevelopmentServers: ['sap-fiori-tools', 'cap-tools']
|
|
269
|
-
});
|
|
270
|
-
assert.deepEqual(Object.keys(result.servers).sort(), ['cap-tools', 'sap-fiori-tools']);
|
|
271
|
-
const written = await readMcpConfig(path);
|
|
272
|
-
assert.deepEqual(Object.keys(written.servers).sort(), ['cap-tools', 'sap-fiori-tools', 'userServer']);
|
|
273
|
-
|
|
274
|
-
await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
275
|
-
const cleared = await readMcpConfig(path);
|
|
276
|
-
assert.deepEqual(Object.keys(cleared.servers), ['userServer']);
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
test('installs and removes the HANA inspector without changing unrelated MCP servers', async t => {
|
|
280
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-hana-config-'));
|
|
281
|
-
const path = join(directory, 'mcp.json');
|
|
282
|
-
const initial = {
|
|
283
|
-
custom: true,
|
|
284
|
-
servers: { userServer: { type: 'stdio', command: 'custom-server' } }
|
|
285
|
-
};
|
|
286
|
-
await writeFile(path, JSON.stringify(initial));
|
|
287
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
288
|
-
|
|
289
|
-
const installed = await installMcpConfig([], {
|
|
290
|
-
env: { H2O_URL: 'http://h2o.example' },
|
|
291
|
-
path,
|
|
292
|
-
sapDevelopmentServers: ['hana-cloud-inspector']
|
|
293
|
-
});
|
|
294
|
-
assert.deepEqual(Object.keys(installed.servers), ['hana-cloud-inspector']);
|
|
295
|
-
const configured = await readMcpConfig(path);
|
|
296
|
-
assert.deepEqual(Object.keys(configured.servers).sort(), ['hana-cloud-inspector', 'userServer']);
|
|
297
|
-
assert.equal(configured.custom, true);
|
|
298
|
-
assert.equal(configured.servers['hana-cloud-inspector'].env, undefined);
|
|
299
|
-
assert.doesNotMatch(JSON.stringify(configured), /"(?:HANA_RO_PASSWORD|VCAP_SERVICES)"\s*:/);
|
|
300
|
-
assert.doesNotMatch(JSON.stringify(configured), /"(?:password|pwd)"\s*:/i);
|
|
301
|
-
|
|
302
|
-
await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
303
|
-
const removed = await readMcpConfig(path);
|
|
304
|
-
assert.deepEqual(removed.servers, initial.servers);
|
|
305
|
-
assert.equal(removed.custom, true);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
test('installs and removes CF entries without deleting unrelated MCP servers', async t => {
|
|
309
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-cf-config-'));
|
|
310
|
-
const path = join(directory, 'mcp.json');
|
|
311
|
-
await writeFile(path, JSON.stringify({
|
|
312
|
-
unrelated: true,
|
|
313
|
-
servers: { userServer: { type: 'stdio', command: 'custom-server' } }
|
|
314
|
-
}));
|
|
315
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
316
|
-
|
|
317
|
-
const result = await installMcpConfig([bas, cfDestination('space-one', 'instance-one', 'managed-key')], {
|
|
318
|
-
env: { H2O_URL: 'http://h2o.example' },
|
|
319
|
-
path,
|
|
320
|
-
command: 'npx',
|
|
321
|
-
args: ['--yes', '--package=sap-ai-dev-toolkit@0.1.0', 'sap-ai-dev']
|
|
322
|
-
});
|
|
323
|
-
assert.deepEqual(Object.keys(result.servers).sort(), ['cf-space-one-instance-one-shared', 'shared']);
|
|
324
|
-
const written = await readMcpConfig(path);
|
|
325
|
-
assert.equal(written.unrelated, true);
|
|
326
|
-
assert.deepEqual(Object.keys(written.servers).sort(), ['cf-space-one-instance-one-shared', 'shared', 'userServer']);
|
|
327
|
-
assert.deepEqual(collectManagedCloudFoundryKeyReferences(written), [
|
|
328
|
-
{ kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'managed-key' }
|
|
329
|
-
]);
|
|
330
|
-
|
|
331
|
-
await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
332
|
-
const cleared = JSON.parse(await readFile(path, 'utf8'));
|
|
333
|
-
assert.deepEqual(Object.keys(cleared.servers), ['userServer']);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test('wizard replaces legacy launcher entries with the branded command and environment', async t => {
|
|
337
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-migration-'));
|
|
338
|
-
const path = join(directory, 'mcp.json');
|
|
339
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
340
|
-
await writeFile(path, JSON.stringify({
|
|
341
|
-
servers: {
|
|
342
|
-
shared: {
|
|
343
|
-
type: 'stdio',
|
|
344
|
-
command: 'bas-vsp-mcp',
|
|
345
|
-
env: { H2O_URL: 'http://h2o.example', BAS_VSP_DESTINATION: 'shared' },
|
|
346
|
-
BAS_EXT: 'true'
|
|
347
|
-
},
|
|
348
|
-
userServer: { type: 'stdio', command: 'custom-server' }
|
|
349
|
-
}
|
|
350
|
-
}));
|
|
351
|
-
|
|
352
|
-
await installMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example', BAS_VSP_MCP_CONFIG: path } });
|
|
353
|
-
const migrated = await readMcpConfig(path);
|
|
354
|
-
assert.deepEqual(Object.keys(migrated.servers).sort(), ['shared', 'userServer']);
|
|
355
|
-
assert.equal(migrated.servers.shared.command, 'sap-ai-dev');
|
|
356
|
-
assert.equal(migrated.servers.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
|
|
357
|
-
assert.equal(migrated.servers.shared.env.BAS_VSP_DESTINATION, undefined);
|
|
358
|
-
assert.equal(migrated.servers.userServer.command, 'custom-server');
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
test('recognizes old npx Cloud Foundry entries so setup can reuse their service keys', () => {
|
|
362
|
-
const current = buildMcpEntries([cfDestination('space-one', 'instance-one', 'old-key')], { H2O_URL: 'http://h2o.example' })['cf-space-one-instance-one-shared'];
|
|
363
|
-
const legacy = {
|
|
364
|
-
...current,
|
|
365
|
-
command: 'npx',
|
|
366
|
-
args: ['--yes', '--package=bas-mcp-addon@0.1.0', 'bas-vsp-mcp'],
|
|
367
|
-
env: {
|
|
368
|
-
...current.env,
|
|
369
|
-
BAS_VSP_DESTINATION_SOURCE: current.env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE,
|
|
370
|
-
BAS_VSP_DESTINATION: current.env.SAP_AI_DEV_TOOLKIT_DESTINATION
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
delete legacy.BAS_EXT;
|
|
374
|
-
delete legacy.env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE;
|
|
375
|
-
delete legacy.env.SAP_AI_DEV_TOOLKIT_DESTINATION;
|
|
376
|
-
assert.deepEqual(collectManagedCloudFoundryKeyReferences({ servers: { legacy } }), [
|
|
377
|
-
{ kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'old-key' }
|
|
378
|
-
]);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
test('setup removes untagged legacy bas-mcp-addon launchers that expose the full VSP surface', async t => {
|
|
382
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-untagged-legacy-'));
|
|
383
|
-
const path = join(directory, 'mcp.json');
|
|
384
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
385
|
-
await writeFile(path, JSON.stringify({
|
|
386
|
-
servers: {
|
|
387
|
-
shared: {
|
|
388
|
-
type: 'stdio',
|
|
389
|
-
command: 'npx',
|
|
390
|
-
args: ['--yes', '--package=bas-mcp-addon@0.2.0', 'bas-vsp-mcp'],
|
|
391
|
-
env: { H2O_URL: 'http://old-h2o.example', BAS_VSP_DESTINATION: 'shared' }
|
|
392
|
-
},
|
|
393
|
-
legacyCommand: {
|
|
394
|
-
type: 'stdio',
|
|
395
|
-
command: 'bas-vsp-mcp',
|
|
396
|
-
env: { H2O_URL: 'http://old-h2o.example', BAS_VSP_DESTINATION: 'other' }
|
|
397
|
-
},
|
|
398
|
-
userServer: { type: 'stdio', command: 'custom-server' }
|
|
399
|
-
}
|
|
400
|
-
}));
|
|
401
|
-
|
|
402
|
-
await installMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
403
|
-
const migrated = await readMcpConfig(path);
|
|
404
|
-
assert.deepEqual(Object.keys(migrated.servers).sort(), ['shared', 'userServer']);
|
|
405
|
-
assert.equal(migrated.servers.shared.command, 'sap-ai-dev');
|
|
406
|
-
assert.equal(migrated.servers.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
test('install registers mixed-case destinations under lowercase server names', async t => {
|
|
410
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-lowercase-install-'));
|
|
411
|
-
const path = join(directory, 'mcp.json');
|
|
412
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
413
|
-
const destination = { ...bas, name: 'ActionS4D', serverName: 'ActionS4D', url: 'http://ActionS4D.dest' };
|
|
414
|
-
await writeFile(path, JSON.stringify({ servers: {} }));
|
|
415
|
-
|
|
416
|
-
const result = await installMcpConfig([destination], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
417
|
-
assert.deepEqual(Object.keys(result.servers), ['actions4d']);
|
|
418
|
-
assert.match(Object.keys(result.servers)[0], /^[a-z0-9-]+$/, 'server names must be lowercase slugs');
|
|
419
|
-
assert.equal(result.servers.actions4d.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'ActionS4D');
|
|
420
|
-
|
|
421
|
-
// Re-running setup replaces a legacy mixed-case managed entry with the slug key.
|
|
422
|
-
const legacy = { ...result.servers.actions4d };
|
|
423
|
-
await writeFile(path, JSON.stringify({ servers: { ActionS4D: legacy, userServer: { command: 'other' } } }));
|
|
424
|
-
await installMcpConfig([destination], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
425
|
-
const written = await readMcpConfig(path);
|
|
426
|
-
assert.deepEqual(Object.keys(written.servers).sort(), ['actions4d', 'userServer']);
|
|
427
|
-
|
|
428
|
-
// Destination names that only differ in case or punctuation now collide.
|
|
429
|
-
assert.throws(
|
|
430
|
-
() => buildMcpEntries([destination, { ...bas, name: 'actions4d' }], { H2O_URL: 'http://h2o.example' }),
|
|
431
|
-
/normalize to the same lowercase server name/
|
|
432
|
-
);
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
test('doctor repair renames legacy mixed-case managed entries to the lowercase slug', async t => {
|
|
436
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-rename-'));
|
|
437
|
-
const path = join(directory, 'mcp.json');
|
|
438
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
439
|
-
const destination = { ...bas, name: 'ActionS4D', serverName: 'ActionS4D', url: 'http://ActionS4D.dest' };
|
|
440
|
-
const legacy = buildMcpEntries([destination], { H2O_URL: 'http://old-h2o.example' }).actions4d;
|
|
441
|
-
const unrelated = { type: 'stdio', command: 'third-party' };
|
|
442
|
-
await writeFile(path, JSON.stringify({ servers: { ActionS4D: legacy, ActionS4D_100: unrelated } }));
|
|
443
|
-
|
|
444
|
-
const first = await repairManagedMcpConfig([destination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
445
|
-
assert.equal(first.changed, true);
|
|
446
|
-
assert.equal(first.repaired, 1);
|
|
447
|
-
const renamed = await readMcpConfig(path);
|
|
448
|
-
assert.equal(renamed.servers.ActionS4D, undefined, 'the legacy mixed-case key must be removed');
|
|
449
|
-
assert.equal(renamed.servers.actions4d.env.H2O_URL, 'http://current-h2o.example');
|
|
450
|
-
assert.equal(renamed.servers.actions4d.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'ActionS4D');
|
|
451
|
-
assert.deepEqual(renamed.servers.ActionS4D_100, unrelated);
|
|
452
|
-
|
|
453
|
-
const beforeSecond = await readFile(path, 'utf8');
|
|
454
|
-
const second = await repairManagedMcpConfig([destination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
455
|
-
assert.equal(second.changed, false);
|
|
456
|
-
assert.equal(second.repaired, 0);
|
|
457
|
-
assert.equal(await readFile(path, 'utf8'), beforeSecond, 'the rename must be idempotent');
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
test('doctor repair keeps the legacy entry when a user-owned server holds the slug key', async t => {
|
|
461
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-rename-blocked-'));
|
|
462
|
-
const path = join(directory, 'mcp.json');
|
|
463
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
464
|
-
const destination = { ...bas, name: 'ActionS4D', serverName: 'ActionS4D', url: 'http://ActionS4D.dest' };
|
|
465
|
-
const legacy = buildMcpEntries([destination], { H2O_URL: 'http://old-h2o.example' }).actions4d;
|
|
466
|
-
const userOwned = { type: 'stdio', command: 'user-server' };
|
|
467
|
-
const initial = { servers: { ActionS4D: legacy, actions4d: userOwned } };
|
|
468
|
-
await writeFile(path, JSON.stringify(initial));
|
|
469
|
-
|
|
470
|
-
const result = await repairManagedMcpConfig([destination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
471
|
-
assert.equal(result.repaired, 0, 'a user-owned entry on the slug key must block the rename');
|
|
472
|
-
const config = await readMcpConfig(path);
|
|
473
|
-
assert.deepEqual(config, initial);
|
|
474
|
-
});
|
|
475
|
-
|