sap-ai-dev-toolkit 0.4.5 → 0.5.5
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 +1 -1
- package/.github/agents/abap-runtime-debugger.agent.md +1 -1
- 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 +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 +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 +1 -1
- 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 +21 -14
- package/package.json +1 -1
- package/scripts/postinstall.mjs +3 -2
- package/src/bas-destination-relay.mjs +48 -22
- package/src/bas-discovery.mjs +16 -6
- package/src/launcher.mjs +24 -14
- package/src/mcp-config.mjs +36 -7
- package/src/mcp-proxy.mjs +185 -51
- package/src/setup.mjs +3 -2
- package/test/bas-destination-relay.test.mjs +93 -14
- package/test/cf-runtime.test.mjs +2 -2
- package/test/discovery.test.mjs +45 -0
- package/test/fixtures/fake-vsp.mjs +6 -3
- package/test/launcher.test.mjs +15 -13
- package/test/live-s4h.test.mjs +13 -11
- package/test/mcp-config-cf.test.mjs +127 -15
- package/test/mcp-proxy.test.mjs +232 -33
- package/test/setup-cf.test.mjs +7 -7
- package/tools.md +4 -2
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
|
-
import { buildMcpEntries, buildSapDevelopmentMcpEntries, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, repairManagedMcpConfig, resolveMcpConfigPath } from '../src/mcp-config.mjs';
|
|
6
|
+
import { buildMcpEntries, buildSapDevelopmentMcpEntries, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, generatedServerName, installMcpConfig, readMcpConfig, repairManagedMcpConfig, resolveMcpConfigPath } from '../src/mcp-config.mjs';
|
|
7
7
|
|
|
8
8
|
const bas = {
|
|
9
9
|
source: 'bas', name: 'shared', serverName: 'shared', url: 'http://shared.dest',
|
|
@@ -38,11 +38,11 @@ test('writes source-qualified CF entries beside same-named BAS destinations', ()
|
|
|
38
38
|
const second = cfDestination('space-one', 'instance-two', 'key-two');
|
|
39
39
|
const entries = buildMcpEntries([bas, first, second], { H2O_URL: 'http://h2o.example' });
|
|
40
40
|
assert.deepEqual(Object.keys(entries).sort(), [
|
|
41
|
-
'cf
|
|
42
|
-
'cf
|
|
41
|
+
'cf-space-one-instance-one-shared',
|
|
42
|
+
'cf-space-one-instance-two-shared',
|
|
43
43
|
'shared'
|
|
44
44
|
]);
|
|
45
|
-
assert.deepEqual(entries['cf
|
|
45
|
+
assert.deepEqual(entries['cf-space-one-instance-one-shared'].env, {
|
|
46
46
|
H2O_URL: 'http://h2o.example',
|
|
47
47
|
SAP_ALLOW_TRANSPORTABLE_EDITS: 'true',
|
|
48
48
|
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
@@ -58,7 +58,7 @@ test('writes source-qualified CF entries beside same-named BAS destinations', ()
|
|
|
58
58
|
assert.equal(JSON.stringify(entries).includes('Password'), false);
|
|
59
59
|
|
|
60
60
|
const onPremise = buildMcpEntries([cfDestination('space-one', 'onprem-instance', 'destination-key', 'OnPremise')], { H2O_URL: 'http://h2o.example' });
|
|
61
|
-
assert.equal(onPremise['cf
|
|
61
|
+
assert.equal(onPremise['cf-space-one-onprem-instance-shared'].env.BAS_CF_CONNECTIVITY_KEY, 'connectivity-key');
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
test('stores and reconciles the MCP launcher executable location', async t => {
|
|
@@ -96,8 +96,10 @@ test('doctor repair updates only discovered toolkit BAS entries and is idempoten
|
|
|
96
96
|
const path = join(directory, 'mcp.json');
|
|
97
97
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
98
98
|
const cf = cfDestination('space-one', 'instance-one', 'cf-key');
|
|
99
|
+
const strictDestination = { ...bas, name: 'strict', serverName: 'strict', url: 'http://strict.dest' };
|
|
99
100
|
const managed = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
|
|
100
|
-
const
|
|
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)];
|
|
101
103
|
const companion = buildSapDevelopmentMcpEntries(['sap-fiori-tools'])['sap-fiori-tools'];
|
|
102
104
|
const unrelated = { type: 'stdio', command: 'external-server' };
|
|
103
105
|
const unverifiedManaged = {
|
|
@@ -108,29 +110,31 @@ test('doctor repair updates only discovered toolkit BAS entries and is idempoten
|
|
|
108
110
|
userFlag: true,
|
|
109
111
|
servers: {
|
|
110
112
|
shared: { ...managed, env: { ...managed.env, H2O_URL: 'http://old-h2o.example' } },
|
|
113
|
+
strict: { ...strictManaged, env: { ...strictManaged.env, SAP_ALLOW_TRANSPORTABLE_EDITS: 'false' } },
|
|
111
114
|
'temporarily-unavailable': unverifiedManaged,
|
|
112
|
-
[cf.serverName]: cfEntry,
|
|
115
|
+
[generatedServerName(cf.serverName)]: cfEntry,
|
|
113
116
|
'sap-fiori-tools': companion,
|
|
114
117
|
ActionS4D_100: unrelated
|
|
115
118
|
}
|
|
116
119
|
};
|
|
117
120
|
await writeFile(path, JSON.stringify(config, null, 2));
|
|
118
121
|
|
|
119
|
-
const first = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
122
|
+
const first = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
120
123
|
assert.equal(first.changed, true);
|
|
121
|
-
assert.equal(first.repaired,
|
|
124
|
+
assert.equal(first.repaired, 2);
|
|
122
125
|
const repaired = await readMcpConfig(path);
|
|
123
126
|
assert.equal(repaired.userFlag, true);
|
|
124
127
|
assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
|
|
125
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");
|
|
126
130
|
assert.deepEqual(repaired.servers['temporarily-unavailable'], unverifiedManaged);
|
|
127
|
-
assert.deepEqual(repaired.servers[cf.serverName], cfEntry);
|
|
131
|
+
assert.deepEqual(repaired.servers[generatedServerName(cf.serverName)], cfEntry);
|
|
128
132
|
assert.deepEqual(repaired.servers['sap-fiori-tools'], companion);
|
|
129
133
|
assert.deepEqual(repaired.servers.ActionS4D_100, unrelated);
|
|
130
134
|
assert.equal(repaired.servers.newDestination, undefined, 'repair must not add unselected destinations');
|
|
131
135
|
|
|
132
136
|
const beforeSecondRepair = await readFile(path, 'utf8');
|
|
133
|
-
const second = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
137
|
+
const second = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
134
138
|
assert.equal(second.changed, false);
|
|
135
139
|
assert.equal(second.repaired, 0);
|
|
136
140
|
assert.equal(await readFile(path, 'utf8'), beforeSecondRepair, 'an already-healed config must not be rewritten');
|
|
@@ -162,10 +166,50 @@ test('doctor repair skips config changes if destination discovery is incomplete'
|
|
|
162
166
|
assert.equal(await readFile(path, 'utf8'), initial);
|
|
163
167
|
});
|
|
164
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
|
+
|
|
165
209
|
test('extracts key references only from package-managed Cloud Foundry entries', () => {
|
|
166
210
|
const cloudFoundry = buildMcpEntries([
|
|
167
211
|
cfDestination('space-one', 'instance-one', 'destination-key', 'OnPremise')
|
|
168
|
-
], { H2O_URL: 'http://h2o.example' })['cf
|
|
212
|
+
], { H2O_URL: 'http://h2o.example' })['cf-space-one-instance-one-shared'];
|
|
169
213
|
const managedNpx = {
|
|
170
214
|
...cloudFoundry,
|
|
171
215
|
command: 'npx',
|
|
@@ -276,10 +320,10 @@ test('installs and removes CF entries without deleting unrelated MCP servers', a
|
|
|
276
320
|
command: 'npx',
|
|
277
321
|
args: ['--yes', '--package=sap-ai-dev-toolkit@0.1.0', 'sap-ai-dev']
|
|
278
322
|
});
|
|
279
|
-
assert.deepEqual(Object.keys(result.servers).sort(), ['cf
|
|
323
|
+
assert.deepEqual(Object.keys(result.servers).sort(), ['cf-space-one-instance-one-shared', 'shared']);
|
|
280
324
|
const written = await readMcpConfig(path);
|
|
281
325
|
assert.equal(written.unrelated, true);
|
|
282
|
-
assert.deepEqual(Object.keys(written.servers).sort(), ['cf
|
|
326
|
+
assert.deepEqual(Object.keys(written.servers).sort(), ['cf-space-one-instance-one-shared', 'shared', 'userServer']);
|
|
283
327
|
assert.deepEqual(collectManagedCloudFoundryKeyReferences(written), [
|
|
284
328
|
{ kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'managed-key' }
|
|
285
329
|
]);
|
|
@@ -315,7 +359,7 @@ test('wizard replaces legacy launcher entries with the branded command and envir
|
|
|
315
359
|
});
|
|
316
360
|
|
|
317
361
|
test('recognizes old npx Cloud Foundry entries so setup can reuse their service keys', () => {
|
|
318
|
-
const current = buildMcpEntries([cfDestination('space-one', 'instance-one', 'old-key')], { H2O_URL: 'http://h2o.example' })['cf
|
|
362
|
+
const current = buildMcpEntries([cfDestination('space-one', 'instance-one', 'old-key')], { H2O_URL: 'http://h2o.example' })['cf-space-one-instance-one-shared'];
|
|
319
363
|
const legacy = {
|
|
320
364
|
...current,
|
|
321
365
|
command: 'npx',
|
|
@@ -361,3 +405,71 @@ test('setup removes untagged legacy bas-mcp-addon launchers that expose the full
|
|
|
361
405
|
assert.equal(migrated.servers.shared.command, 'sap-ai-dev');
|
|
362
406
|
assert.equal(migrated.servers.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
|
|
363
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
|
+
|