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.
Files changed (38) hide show
  1. package/.github/agents/abap-developer.agent.md +1 -1
  2. package/.github/agents/abap-runtime-debugger.agent.md +1 -1
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
  4. package/.github/agents/rap-service-developer.agent.md +1 -1
  5. package/.github/agents/sap-solution-architect.agent.md +1 -1
  6. package/.github/skills/abap-debugging/SKILL.md +1 -1
  7. package/.github/skills/abap-development/SKILL.md +1 -1
  8. package/.github/skills/abap-runtime-analysis/SKILL.md +1 -1
  9. package/.github/skills/abap-testing-quality/SKILL.md +1 -1
  10. package/.github/skills/cds-development/SKILL.md +1 -1
  11. package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
  12. package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
  13. package/.github/skills/hana-cloud-native-development/SKILL.md +1 -1
  14. package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
  15. package/.github/skills/rap-development/SKILL.md +1 -1
  16. package/.github/skills/rap-service-delivery/SKILL.md +1 -1
  17. package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
  18. package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
  19. package/.github/skills/sap-transport-release/SKILL.md +1 -1
  20. package/README.md +21 -14
  21. package/package.json +1 -1
  22. package/scripts/postinstall.mjs +3 -2
  23. package/src/bas-destination-relay.mjs +48 -22
  24. package/src/bas-discovery.mjs +16 -6
  25. package/src/launcher.mjs +24 -14
  26. package/src/mcp-config.mjs +36 -7
  27. package/src/mcp-proxy.mjs +185 -51
  28. package/src/setup.mjs +3 -2
  29. package/test/bas-destination-relay.test.mjs +93 -14
  30. package/test/cf-runtime.test.mjs +2 -2
  31. package/test/discovery.test.mjs +45 -0
  32. package/test/fixtures/fake-vsp.mjs +6 -3
  33. package/test/launcher.test.mjs +15 -13
  34. package/test/live-s4h.test.mjs +13 -11
  35. package/test/mcp-config-cf.test.mjs +127 -15
  36. package/test/mcp-proxy.test.mjs +232 -33
  37. package/test/setup-cf.test.mjs +7 -7
  38. 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:space-one:instance-one:shared',
42
- 'cf:space-one:instance-two:shared',
41
+ 'cf-space-one-instance-one-shared',
42
+ 'cf-space-one-instance-two-shared',
43
43
  'shared'
44
44
  ]);
45
- assert.deepEqual(entries['cf:space-one:instance-one:shared'].env, {
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:space-one:onprem-instance:shared'].env.BAS_CF_CONNECTIVITY_KEY, 'connectivity-key');
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 cfEntry = buildMcpEntries([cf], { H2O_URL: 'http://cf-h2o.example' })[cf.serverName];
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, 1);
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:space-one:instance-one:shared'];
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:space-one:instance-one:shared', 'shared']);
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:space-one:instance-one:shared', 'shared', 'userServer']);
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:space-one:instance-one:shared'];
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
+