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.
Files changed (57) hide show
  1. package/.github/agents/abap-developer.agent.md +3 -3
  2. package/.github/agents/abap-runtime-debugger.agent.md +3 -3
  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 +3 -3
  7. package/.github/skills/abap-development/SKILL.md +1 -1
  8. package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
  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 +2 -2
  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 +6 -6
  21. package/inventory.md +179 -0
  22. package/package.json +2 -2
  23. package/scripts/build-vsp.mjs +2 -1
  24. package/scripts/postinstall.mjs +14 -16
  25. package/src/bas-destination-relay.mjs +25 -3
  26. package/src/bas-discovery.mjs +9 -3
  27. package/src/binary.mjs +101 -17
  28. package/src/cf-destination.mjs +55 -1
  29. package/src/credentials-store.mjs +0 -10
  30. package/src/engineering-tools.mjs +32 -10
  31. package/src/hana-tools.mjs +9 -2
  32. package/src/launcher.mjs +8 -11
  33. package/src/mcp-config.mjs +19 -1
  34. package/src/mcp-proxy.mjs +230 -76
  35. package/src/redact.mjs +28 -0
  36. package/src/setup.mjs +30 -1
  37. package/tools.md +1 -1
  38. package/test/bas-destination-relay.test.mjs +0 -415
  39. package/test/cf-connectivity.test.mjs +0 -287
  40. package/test/cf-destination.test.mjs +0 -307
  41. package/test/cf-runtime.test.mjs +0 -527
  42. package/test/copilot-assets.test.mjs +0 -63
  43. package/test/copilot-content.test.mjs +0 -249
  44. package/test/credentials-store.test.mjs +0 -187
  45. package/test/discovery.test.mjs +0 -282
  46. package/test/fixtures/fake-vsp.mjs +0 -266
  47. package/test/hana-config.test.mjs +0 -168
  48. package/test/hana-inspector-stdio.test.mjs +0 -44
  49. package/test/hana-tools.test.mjs +0 -206
  50. package/test/launcher.test.mjs +0 -401
  51. package/test/live-s4h.test.mjs +0 -204
  52. package/test/mcp-config-cf.test.mjs +0 -475
  53. package/test/mcp-proxy.test.mjs +0 -604
  54. package/test/pty.mjs +0 -8
  55. package/test/setup-cf.test.mjs +0 -489
  56. package/test/setup.test.mjs +0 -417
  57. package/test/terminal-ui.test.mjs +0 -47
@@ -1,401 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { createServer } from 'node:http';
4
- import { spawn } from 'node:child_process';
5
- import { fileURLToPath } from 'node:url';
6
- import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
7
- import { tmpdir } from 'node:os';
8
- import { join } from 'node:path';
9
- import { spawnWithPty } from './pty.mjs';
10
- import { storeDestinationCredentials } from '../src/credentials-store.mjs';
11
- import { credentialKeyForDestination, enrichWithStoredCredentials } from '../src/credential-overrides.mjs';
12
-
13
- const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
14
- const fakeVsp = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
15
-
16
- function runLauncher(args, env) {
17
- return new Promise((resolve, reject) => {
18
- const child = spawn(process.execPath, [launcher, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
19
- let stdout = '';
20
- let stderr = '';
21
- child.stdout.on('data', chunk => { stdout += chunk; });
22
- child.stderr.on('data', chunk => { stderr += chunk; });
23
- child.on('error', reject);
24
- child.on('exit', (code, signal) => resolve({ code, signal, stdout, stderr }));
25
- });
26
- }
27
- function runLauncherTty(env, input, args = ['--setup']) {
28
- return new Promise((resolve, reject) => {
29
- const command = `${process.execPath} ${launcher} ${args.join(' ')}`;
30
- const child = spawnWithPty(command, { env, stdio: ['pipe', 'pipe', 'pipe'] });
31
- let stdout = '';
32
- let stderr = '';
33
- let inputSent = false;
34
- const timeout = setTimeout(() => child.kill('SIGKILL'), 15000);
35
- child.stdout.on('data', chunk => {
36
- stdout += chunk.toString();
37
- if (!inputSent && stdout.includes('Select destinations')) {
38
- inputSent = true;
39
- child.stdin.end(input);
40
- }
41
- });
42
- child.stderr.on('data', chunk => { stderr += chunk.toString(); });
43
- child.on('error', error => { clearTimeout(timeout); reject(error); });
44
- child.on('exit', (code, signal) => { clearTimeout(timeout); resolve({ code, signal, stdout, stderr, inputSent }); });
45
- });
46
- }
47
-
48
- test('enriches stored credentials without switching BAS OnPremise or CF Connectivity routes', async t => {
49
- const directory = await mkdtemp(join(tmpdir(), 'bas-launcher-credentials-'));
50
- const credentialsPath = join(directory, 'credentials.json');
51
- t.after(async () => { await rm(directory, { recursive: true, force: true }); });
52
-
53
- const internet = {
54
- name: 'internet-system', url: 'http://internet-system.dest', backendUrl: 'https://internet.example:44300',
55
- authentication: 'BasicAuthentication', proxyType: 'Internet'
56
- };
57
- const basOnPremise = {
58
- name: 'shared-system', url: 'http://shared-system.dest', backendUrl: 'https://internal.example:44300',
59
- authentication: 'BasicAuthentication', proxyType: 'OnPremise'
60
- };
61
- const cfOnPremise = {
62
- source: 'cloud-foundry', name: 'shared-system', serverName: 'cf:space-one:instance-one:shared-system',
63
- url: 'https://sap.internal:44300', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
64
- childEnv: { HTTP_PROXY: 'http://127.0.0.1:3333', SAP_USER: 'configured-user', SAP_PASSWORD: 'configured-password' }
65
- };
66
- const principalPropagation = { ...cfOnPremise, name: 'principal-system', serverName: 'cf:space-one:instance-one:principal-system', authentication: 'PrincipalPropagation' };
67
-
68
- await storeDestinationCredentials(credentialsPath, 'internet-system', {
69
- host: internet.backendUrl, user: 'INTERNET_USER', password: 'internet-secret'
70
- });
71
- await storeDestinationCredentials(credentialsPath, 'shared-system', {
72
- user: 'BAS_USER', password: 'bas-secret', mode: 'bas-tunnel'
73
- });
74
- await storeDestinationCredentials(credentialsPath, credentialKeyForDestination(cfOnPremise), {
75
- user: 'CF_USER', password: 'cf-secret', mode: 'cf-connectivity'
76
- });
77
- await storeDestinationCredentials(credentialsPath, credentialKeyForDestination(principalPropagation), {
78
- user: 'SHOULD_NOT_APPLY', password: 'pp-secret', mode: 'cf-connectivity'
79
- });
80
-
81
- const result = await enrichWithStoredCredentials(
82
- [internet, basOnPremise, cfOnPremise, principalPropagation],
83
- { SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE: credentialsPath },
84
- () => {}
85
- );
86
- assert.deepEqual(result[0].credentials, {
87
- host: internet.backendUrl, user: 'INTERNET_USER', password: 'internet-secret', mode: 'direct'
88
- });
89
- assert.deepEqual(result[1].credentials, { user: 'BAS_USER', password: 'bas-secret', mode: 'bas-tunnel' });
90
- assert.equal(result[1].credentials.host, undefined, 'OnPremise must not bypass the BAS destination route');
91
- assert.deepEqual(result[2].childEnv, {
92
- HTTP_PROXY: 'http://127.0.0.1:3333', SAP_USER: 'CF_USER', SAP_PASSWORD: 'cf-secret', SAP_VERBOSE: 'false'
93
- });
94
- assert.equal(result[3].childEnv.SAP_USER, 'configured-user', 'PrincipalPropagation must not receive a Basic override');
95
- assert.equal(result[3].childEnv.SAP_PASSWORD, 'configured-password');
96
- });
97
-
98
- test('help exits before BAS destination discovery', async t => {
99
- let requests = 0;
100
- const server = createServer((_request, response) => {
101
- requests++;
102
- response.setHeader('content-type', 'application/json');
103
- response.end('[]');
104
- });
105
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
106
- t.after(() => new Promise(resolve => server.close(resolve)));
107
-
108
- const result = await runLauncher(['--help'], { ...process.env, H2O_URL: `http://127.0.0.1:${server.address().port}`, NO_PROXY: '127.0.0.1' });
109
- assert.equal(result.code, 0, result.stderr);
110
- assert.equal(result.stderr, '');
111
- assert.match(result.stdout, /Usage: sap-ai-dev/);
112
- assert.match(result.stdout, /Use an MCP client to call server tools/);
113
- assert.equal(requests, 0);
114
- });
115
-
116
- test('doctor reports missing BAS configuration as redacted JSON', async () => {
117
- const result = await runLauncher(['--doctor', '--json'], {
118
- ...process.env,
119
- H2O_URL: '',
120
- SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: '',
121
- BAS_VSP_DESTINATION_SOURCE: ''
122
- });
123
- assert.equal(result.code, 1);
124
- assert.equal(result.stderr, '');
125
- assert.deepEqual(JSON.parse(result.stdout), {
126
- ok: false,
127
- destinations: 0,
128
- checks: [{
129
- name: '-',
130
- stage: 'destination discovery',
131
- status: 'failed',
132
- detail: 'H2O_URL is required for BAS destination discovery'
133
- }]
134
- });
135
- });
136
-
137
- test('doctor preflights the failing ADT endpoint through the runtime relay and repairs its owned config', async t => {
138
- const directory = await mkdtemp(join(tmpdir(), 'bas-doctor-csrf-'));
139
- const configPath = join(directory, 'mcp.json');
140
- const config = {
141
- servers: {
142
- 'doctor-system': {
143
- type: 'stdio',
144
- command: 'sap-ai-dev',
145
- env: {
146
- H2O_URL: 'http://stale-bas.example',
147
- SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system'
148
- },
149
- BAS_EXT: 'true'
150
- },
151
- ActionS4D_100: { type: 'stdio', command: 'third-party' }
152
- }
153
- };
154
- await writeFile(configPath, JSON.stringify(config));
155
- const requests = [];
156
- const server = createServer((request, response) => {
157
- let requestPath;
158
- try { requestPath = new URL(request.url, `http://${request.headers.host}`).pathname; }
159
- catch { requestPath = request.url; }
160
- requests.push({ method: request.method, path: requestPath, csrf: request.headers['x-csrf-token'], cookie: request.headers.cookie });
161
- if (request.url === '/api/listDestinations') {
162
- response.setHeader('content-type', 'application/json');
163
- response.end(JSON.stringify([{
164
- Name: 'doctor-system',
165
- WebIDEEnabled: true,
166
- 'HTML5.DynamicDestination': true,
167
- WebIDEUsage: 'dev_abap',
168
- Authentication: 'BasicAuthentication'
169
- }]));
170
- return;
171
- }
172
- if (requestPath === '/sap/bc/adt/discovery' && request.method === 'GET' && request.headers['x-csrf-token'] === 'Fetch') {
173
- // Real SAP issues CSRF tokens from GET-capable endpoints only.
174
- response.writeHead(200, {
175
- 'x-csrf-token': 'doctor-token',
176
- 'set-cookie': 'sap-session=doctor-session; Path=/'
177
- });
178
- response.end('<ok/>');
179
- return;
180
- }
181
- if (requestPath === '/sap/bc/adt/datapreview/freestyle') {
182
- if (request.method === 'POST'
183
- && request.headers['x-csrf-token'] === 'doctor-token'
184
- && String(request.headers.cookie).includes('sap-session=doctor-session')) {
185
- response.writeHead(200);
186
- response.end('<written/>');
187
- return;
188
- }
189
- response.writeHead(403, { 'x-csrf-token': 'Required' });
190
- response.end('CSRF token validation failed');
191
- return;
192
- }
193
- response.writeHead(200);
194
- response.end('<ok/>');
195
- });
196
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
197
- t.after(async () => {
198
- await new Promise(resolve => server.close(resolve));
199
- await rm(directory, { recursive: true, force: true });
200
- });
201
- const proxy = `http://127.0.0.1:${server.address().port}`;
202
- const result = await runLauncher(['--doctor', '--json'], {
203
- ...process.env,
204
- SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
205
- FAKE_RELAY_CSRF_TEST: 'true',
206
- SAP_AI_DEV_MCP_CONFIG: configPath,
207
- SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system',
208
- H2O_URL: `http://127.0.0.1:${server.address().port}`,
209
- HTTP_PROXY: proxy,
210
- http_proxy: proxy,
211
- NO_PROXY: '127.0.0.1,localhost',
212
- no_proxy: '127.0.0.1,localhost'
213
- });
214
- assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`);
215
- const report = JSON.parse(result.stdout);
216
- assert.equal(report.ok, true);
217
- assert.ok(report.checks.some(check => check.stage === 'BAS relay CSRF preflight' && check.status === 'passed'));
218
- assert.ok(report.checks.some(check => check.stage === 'SAP system check' && check.status === 'passed'));
219
- assert.ok(report.checks.some(check => check.stage === 'MCP config repair' && check.status === 'passed' && /1 managed BAS entry repaired/.test(check.detail)));
220
- assert.ok(report.checks.some(check => check.stage === 'MCP tools/list' && /total MCP tools returned; chat-picker binding is host-managed/.test(check.detail)));
221
- assert.ok(requests.some(request => request.method === 'GET' && request.path === '/sap/bc/adt/discovery' && request.csrf === 'Fetch'));
222
- assert.ok(requests.some(request => request.method === 'POST' && request.path === '/sap/bc/adt/datapreview/freestyle' && request.csrf === 'doctor-token' && String(request.cookie).includes('sap-session=doctor-session')));
223
- const repairedConfig = JSON.parse(await readFile(configPath, 'utf8'));
224
- assert.equal(repairedConfig.servers['doctor-system'].env.H2O_URL, `http://127.0.0.1:${server.address().port}`);
225
- assert.deepEqual(repairedConfig.servers.ActionS4D_100, config.servers.ActionS4D_100);
226
- });
227
-
228
- test('doctor stops before GetSystemInfo when the runtime CSRF preflight receives no token', async t => {
229
- const directory = await mkdtemp(join(tmpdir(), 'bas-doctor-csrf-missing-cookie-'));
230
- const configPath = join(directory, 'mcp.json');
231
- await writeFile(configPath, JSON.stringify({ servers: {} }));
232
- const requests = [];
233
- const server = createServer((request, response) => {
234
- let requestPath;
235
- try { requestPath = new URL(request.url, `http://${request.headers.host}`).pathname; }
236
- catch { requestPath = request.url; }
237
- requests.push({ method: request.method, path: requestPath, csrf: request.headers['x-csrf-token'] });
238
- if (request.url === '/api/listDestinations') {
239
- response.setHeader('content-type', 'application/json');
240
- response.end(JSON.stringify([{
241
- Name: 'doctor-system',
242
- WebIDEEnabled: true,
243
- 'HTML5.DynamicDestination': true,
244
- WebIDEUsage: 'dev_abap',
245
- Authentication: 'BasicAuthentication'
246
- }]));
247
- return;
248
- }
249
- if (requestPath === '/sap/bc/adt/discovery' && request.method === 'GET' && request.headers['x-csrf-token'] === 'Fetch') {
250
- // Issues no token at all: the relay must fail closed before any write.
251
- response.writeHead(200);
252
- response.end('<ok/>');
253
- return;
254
- }
255
- response.writeHead(200);
256
- response.end('<ok/>');
257
- });
258
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
259
- t.after(async () => {
260
- await new Promise(resolve => server.close(resolve));
261
- await rm(directory, { recursive: true, force: true });
262
- });
263
- const proxy = `http://127.0.0.1:${server.address().port}`;
264
- const result = await runLauncher(['--doctor', '--json'], {
265
- ...process.env,
266
- SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
267
- FAKE_RELAY_CSRF_TEST: 'true',
268
- SAP_AI_DEV_MCP_CONFIG: configPath,
269
- SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system',
270
- H2O_URL: `http://127.0.0.1:${server.address().port}`,
271
- HTTP_PROXY: proxy,
272
- http_proxy: proxy,
273
- NO_PROXY: '127.0.0.1,localhost',
274
- no_proxy: '127.0.0.1,localhost'
275
- });
276
- assert.equal(result.code, 1);
277
- const report = JSON.parse(result.stdout);
278
- assert.equal(report.ok, false);
279
- assert.ok(report.checks.some(check => check.stage === 'BAS relay CSRF preflight' && check.status === 'failed' && /cookies=0; usableSession=false/.test(check.detail)));
280
- assert.ok(report.checks.some(check => check.stage === 'SAP system check' && check.status === 'skipped' && /validated BAS proxy tunnel credential override/.test(check.detail)));
281
- assert.equal(requests.some(request => request.method === 'GET' && request.path === '/sap/bc/adt/discovery' && request.csrf === 'Fetch'), true);
282
- assert.equal(requests.some(request => request.method === 'POST' && request.path === '/sap/bc/adt/datapreview/freestyle'), false, 'doctor must not call GetSystemInfo after a failed preflight');
283
- });
284
-
285
- test('list-destinations JSON is redacted and probes through the BAS proxy', async () => {
286
- const requests = [];
287
- const server = createServer((request, response) => {
288
- requests.push({ host: request.headers.host, path: request.url });
289
- if (request.url === '/api/listDestinations') {
290
- response.setHeader('content-type', 'application/json');
291
- response.end(JSON.stringify([{ Name: 'launch-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap', Authentication: 'PrincipalPropagation', Password: 'do-not-print' }]));
292
- return;
293
- }
294
- response.writeHead(403);
295
- response.end();
296
- });
297
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
298
- const port = server.address().port;
299
- const result = await runLauncher(['--list-destinations', '--json'], { ...process.env, H2O_URL: `http://127.0.0.1:${port}`, HTTP_PROXY: `http://127.0.0.1:${port}`, HTTPS_PROXY: `http://127.0.0.1:${port}`, NO_PROXY: '127.0.0.1' });
300
- await new Promise(resolve => server.close(resolve));
301
- assert.equal(result.code, 0, result.stderr);
302
- assert.deepEqual(JSON.parse(result.stdout), [{ name: 'launch-system', client: '001', authentication: 'PrincipalPropagation', probe: 'auth-required' }]);
303
- assert.deepEqual(requests, [
304
- { host: `127.0.0.1:${port}`, path: '/api/listDestinations' },
305
- { host: 'launch-system.dest', path: 'http://launch-system.dest/sap/bc/adt/discovery' }
306
- ]);
307
- assert.equal(result.stdout.includes('do-not-print'), false);
308
- });
309
- test('runtime starts destinations even when their ADT probes fail', async () => {
310
- const server = createServer((request, response) => {
311
- if (request.url.includes('/api/listDestinations')) {
312
- response.setHeader('content-type', 'application/json');
313
- response.end(JSON.stringify([{ Name: 'launch-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap' }]));
314
- return;
315
- }
316
- response.writeHead(503);
317
- response.end();
318
- });
319
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
320
- const proxy = `http://127.0.0.1:${server.address().port}`;
321
- try {
322
- const result = await runLauncher([], {
323
- ...process.env,
324
- SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
325
- H2O_URL: 'http://bas.example',
326
- SAP_AI_DEV_TOOLKIT_DESTINATION: 'launch-system',
327
- HTTP_PROXY: proxy,
328
- http_proxy: proxy,
329
- NO_PROXY: '',
330
- no_proxy: ''
331
- });
332
- assert.equal(result.code, 0, result.stderr);
333
- assert.equal(result.stdout, '');
334
- assert.match(result.stderr, /launch-system: probe=unavailable \(HTTP 503\)/);
335
- assert.match(result.stderr, /starting MCP proxy for launch-system \(client=001\)/);
336
- assert.match(result.stderr, /\[launch-system\] starting VSP child/);
337
- } finally {
338
- await new Promise(resolve => server.close(resolve));
339
- }
340
- });
341
- test('setup subprocess writes one isolated MCP entry per selected destination', async () => {
342
- const directory = await mkdtemp(join(tmpdir(), 'bas-launcher-setup-'));
343
- const bin = join(directory, 'bin');
344
- await mkdir(bin);
345
- const cfCli = join(bin, 'cf');
346
- await writeFile(cfCli, '#!/bin/sh\nexit 1\n');
347
- await chmod(cfCli, 0o755);
348
- const config = join(directory, 'mcp.json');
349
- await writeFile(config, JSON.stringify({ inputs: [], servers: {
350
- unrelated: { type: 'stdio', command: 'other' },
351
- 'alpha-system': {
352
- type: 'stdio',
353
- command: 'bas-vsp-mcp',
354
- env: { H2O_URL: 'http://old-h2o.example', BAS_VSP_DESTINATION: 'alpha-system' },
355
- BAS_EXT: 'true'
356
- }
357
- } }));
358
- const server = createServer((request, response) => {
359
- if (request.url === '/api/listDestinations') {
360
- response.setHeader('content-type', 'application/json');
361
- response.end(JSON.stringify([
362
- { Name: 'alpha-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap', 'SAP-Client': '100' },
363
- { Name: 'beta-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap', 'SAP-Client': '200' }
364
- ]));
365
- return;
366
- }
367
- response.writeHead(404);
368
- response.end();
369
- });
370
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
371
- const env = { ...process.env, PATH: `${bin}:${process.env.PATH || ''}`, H2O_URL: `http://127.0.0.1:${server.address().port}`, SAP_AI_DEV_TOOLKIT_SKIP_PROBE: 'true', SAP_AI_DEV_TOOLKIT_MCP_CONFIG: config };
372
- try {
373
- const first = await runLauncherTty(env, 'a\r', ['--setup', '--npx']);
374
- assert.equal(first.code, 0, `${first.stdout}\n${first.stderr}`);
375
- let current = JSON.parse(await readFile(config, 'utf8'));
376
- let generated = Object.entries(current.servers).filter(([, entry]) => entry.BAS_EXT === 'true');
377
- assert.deepEqual(generated.map(([, entry]) => entry.env.SAP_AI_DEV_TOOLKIT_DESTINATION), ['alpha-system', 'beta-system'], `${first.stdout}\n${first.stderr}`);
378
- assert.equal(current.servers['alpha-system'].env.BAS_VSP_DESTINATION, undefined);
379
- assert.equal(generated.every(([, entry]) => entry.env.H2O_URL === env.H2O_URL), true);
380
- const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
381
- assert.deepEqual(generated.map(([, entry]) => [entry.command, entry.args]), [
382
- ['npx', ['--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${packageJson.version}`, 'sap-ai-dev']],
383
- ['npx', ['--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${packageJson.version}`, 'sap-ai-dev']]
384
- ]);
385
- const second = await runLauncherTty(env, '\r', ['--setup', '--npx']);
386
- assert.equal(second.code, 0, `${second.stdout}\\n${second.stderr}`);
387
- current = JSON.parse(await readFile(config, 'utf8'));
388
- generated = Object.keys(current.servers).filter(name => name !== 'unrelated');
389
- assert.deepEqual(generated, []);
390
- assert.deepEqual(current.servers.unrelated, { type: 'stdio', command: 'other' });
391
- } finally {
392
- await new Promise(resolve => server.close(resolve));
393
- await rm(directory, { recursive: true, force: true });
394
- }
395
- });
396
-
397
- test('non-BAS list mode reports the required discovery boundary', async () => {
398
- const result = await runLauncher(['--list-destinations', '--json'], { ...process.env, H2O_URL: '' });
399
- assert.equal(result.code, 1);
400
- assert.match(result.stderr, /H2O_URL is required/);
401
- });
@@ -1,204 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { spawn } from 'node:child_process';
4
- import { once } from 'node:events';
5
- import { readFile } from 'node:fs/promises';
6
- import { homedir } from 'node:os';
7
- import { join } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
- import { slugifyDestination } from '../src/bas-discovery.mjs';
10
- import { snakeCaseName, TOOL_NAME_PREFIX } from '../src/mcp-proxy.mjs';
11
-
12
- const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
13
- const LIVE = /^(1|true|yes)$/iu.test(process.env.SAP_AI_DEV_LIVE_S4H || '');
14
- const DESTINATION = process.env.SAP_AI_DEV_LIVE_DESTINATION || 'S4H';
15
- const SLUG = process.env.SAP_AI_DEV_LIVE_DESTINATION_SLUG || slugifyDestination(DESTINATION);
16
- const REQUEST_TIMEOUT_MS = Number(process.env.SAP_AI_DEV_LIVE_TIMEOUT_MS || 45_000);
17
-
18
- async function readConfiguredServer() {
19
- if (/^(0|false|no)$/iu.test(process.env.SAP_AI_DEV_LIVE_USE_MCP_CONFIG || '')) return null;
20
- const candidates = [
21
- process.env.SAP_AI_DEV_MCP_CONFIG,
22
- process.env.BAS_VSP_MCP_CONFIG,
23
- join(homedir(), '.vscode', 'data', 'User', 'mcp.json'),
24
- join(homedir(), '.vscode-server', 'data', 'User', 'mcp.json'),
25
- join(homedir(), '.code-server', 'data', 'User', 'mcp.json')
26
- ].filter(Boolean);
27
- for (const path of candidates) {
28
- let parsed;
29
- try {
30
- parsed = JSON.parse(await readFile(path, 'utf8'));
31
- } catch {
32
- continue;
33
- }
34
- const server = parsed?.servers?.[SLUG] || parsed?.servers?.[DESTINATION];
35
- if (server?.command) return { path, server };
36
- }
37
- return null;
38
- }
39
-
40
- class StdioMcpClient {
41
- constructor(command, args, env) {
42
- this.child = spawn(command, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
43
- this.nextId = 1;
44
- this.pending = new Map();
45
- this.stdout = '';
46
- this.stderr = '';
47
- this.notifications = [];
48
- this.child.stdout.setEncoding('utf8');
49
- this.child.stderr.setEncoding('utf8');
50
- this.child.stdout.on('data', chunk => this.#onStdout(chunk));
51
- this.child.stderr.on('data', chunk => { this.stderr += chunk; });
52
- this.child.on('exit', (code, signal) => {
53
- const error = new Error(`MCP process exited (${code ?? signal})\n${this.stderr.slice(-4000)}`);
54
- for (const pending of this.pending.values()) pending.reject(error);
55
- this.pending.clear();
56
- });
57
- }
58
-
59
- #onStdout(chunk) {
60
- this.stdout += chunk;
61
- let newline;
62
- while ((newline = this.stdout.indexOf('\n')) >= 0) {
63
- const line = this.stdout.slice(0, newline).trim();
64
- this.stdout = this.stdout.slice(newline + 1);
65
- if (!line) continue;
66
- let message;
67
- try {
68
- message = JSON.parse(line);
69
- } catch (error) {
70
- for (const pending of this.pending.values()) pending.reject(new Error(`Invalid JSON-RPC line: ${line}`));
71
- this.pending.clear();
72
- return;
73
- }
74
- if (message.id === undefined || message.id === null) {
75
- this.notifications.push(message);
76
- continue;
77
- }
78
- const pending = this.pending.get(message.id);
79
- if (!pending) continue;
80
- this.pending.delete(message.id);
81
- clearTimeout(pending.timeout);
82
- if (message.error) pending.reject(Object.assign(new Error(message.error.message), { response: message }));
83
- else pending.resolve(message.result);
84
- }
85
- }
86
-
87
- request(method, params) {
88
- const id = this.nextId++;
89
- const payload = { jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) };
90
- return new Promise((resolve, reject) => {
91
- const timeout = setTimeout(() => {
92
- this.pending.delete(id);
93
- reject(new Error(`Timed out waiting for ${method}\n${this.stderr.slice(-4000)}`));
94
- }, REQUEST_TIMEOUT_MS);
95
- this.pending.set(id, { resolve, reject, timeout });
96
- this.child.stdin.write(`${JSON.stringify(payload)}\n`);
97
- });
98
- }
99
-
100
- notify(method, params) {
101
- this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) })}\n`);
102
- }
103
-
104
- async close() {
105
- this.child.stdin.end();
106
- this.child.kill('SIGTERM');
107
- await Promise.race([
108
- once(this.child, 'exit').catch(() => {}),
109
- new Promise(resolve => setTimeout(resolve, 1500))
110
- ]);
111
- if (!this.child.killed) this.child.kill('SIGKILL');
112
- }
113
- }
114
-
115
- function textPayload(result) {
116
- return (result?.content || []).filter(item => item.type === 'text').map(item => item.text).join('\n');
117
- }
118
-
119
- async function withLiveClient(t) {
120
- const configured = await readConfiguredServer();
121
- const server = configured?.server;
122
- const command = process.env.SAP_AI_DEV_LIVE_COMMAND || server?.command || process.execPath;
123
- const args = process.env.SAP_AI_DEV_LIVE_COMMAND
124
- ? (process.env.SAP_AI_DEV_LIVE_ARGS ? JSON.parse(process.env.SAP_AI_DEV_LIVE_ARGS) : [])
125
- : (server?.command ? (server.args || []) : [launcher]);
126
- const client = new StdioMcpClient(command, args, {
127
- ...process.env,
128
- ...(server?.env || {}),
129
- SAP_AI_DEV_TOOLKIT_DESTINATION: server?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION || DESTINATION,
130
- SAP_AI_DEV_TOOLKIT_MODE: process.env.SAP_AI_DEV_TOOLKIT_MODE || server?.env?.SAP_AI_DEV_TOOLKIT_MODE || 'expert',
131
- SAP_ALLOW_TRANSPORTABLE_EDITS: process.env.SAP_ALLOW_TRANSPORTABLE_EDITS || server?.env?.SAP_ALLOW_TRANSPORTABLE_EDITS || 'true'
132
- });
133
- t.after(() => client.close());
134
- const initialized = await client.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'sap-ai-dev-live-test', version: '1.0.0' } });
135
- client.notify('notifications/initialized');
136
- assert.equal(initialized.serverInfo.name, SLUG);
137
- return client;
138
- }
139
-
140
- test('live S4H MCP exposes a curated ABAP lifecycle surface under 60 tools', { skip: LIVE ? false : 'set SAP_AI_DEV_LIVE_S4H=1 to run against a live BAS/S4H destination' }, async t => {
141
- const client = await withLiveClient(t);
142
- const listed = await client.request('tools/list', {});
143
- const tools = listed.tools || [];
144
- const names = new Set(tools.map(tool => tool.name));
145
- const prefix = `${TOOL_NAME_PREFIX}${SLUG}_`;
146
-
147
- assert.ok(tools.length > 25, `expected a useful ABAP lifecycle surface, got ${tools.length}`);
148
- assert.ok(tools.length < 60, `curated destination tool count must stay below 60, got ${tools.length}`);
149
- assert.ok(tools.every(tool => tool.name.startsWith(prefix)), 'every tool is destination-prefixed for the selected S4H server');
150
-
151
- for (const required of [
152
- 'LintABAP', 'GetSystemInfo', 'GetConnectionInfo', 'GetFeatures',
153
- 'SearchObject', 'GrepObjects', 'GrepPackages', 'GetSource', 'WriteSource', 'EditSource', 'CompareSource',
154
- 'SyntaxCheck', 'PrettyPrint', 'Activate', 'RunUnitTests', 'RunATCCheck', 'GetInactiveObjects',
155
- 'GetClass', 'GetProgram', 'GetPackage', 'GetTable', 'GetTableContents', 'RunQuery',
156
- 'GetTransport', 'GetTransportInfo', 'ListTransports', 'CreateTransport',
157
- 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration'
158
- ]) {
159
- assert.ok(names.has(`${prefix}${snakeCaseName(required)}`), `expected curated lifecycle tool ${required}`);
160
- }
161
-
162
- for (const hidden of [
163
- 'SAP', 'ReleaseTransport', 'DeleteTransport', 'DeleteObject', 'ExecuteABAP', 'CallRFC',
164
- 'ListSQLTraces', 'AnalyzeABAPCode', 'CreateObject', 'CreateClassWithTests', 'WriteClass', 'UpdateSource'
165
- ]) {
166
- assert.equal(names.has(`${prefix}${snakeCaseName(hidden)}`), false, `broad/destructive tool ${hidden} must remain hidden`);
167
- }
168
- });
169
-
170
- test('live S4H MCP handles safe local and read-only SAP calls', { skip: LIVE ? false : 'set SAP_AI_DEV_LIVE_S4H=1 to run against a live BAS/S4H destination' }, async t => {
171
- const client = await withLiveClient(t);
172
- const prefix = `${TOOL_NAME_PREFIX}${SLUG}_`;
173
- await client.request('tools/list', {});
174
-
175
- const lint = await client.request('tools/call', {
176
- name: `${prefix}lint_abap`,
177
- arguments: { files: [{ filename: 'zlive_probe.prog.abap', source: "REPORT zlive_probe.\nWRITE / 'ok'.\n" }] }
178
- });
179
- const lintJson = JSON.parse(textPayload(lint));
180
- assert.equal(lintJson.filesChecked, 1);
181
-
182
- for (const [tool, args] of [
183
- ['GetSystemInfo', {}],
184
- ['GetConnectionInfo', {}],
185
- ['GetFeatures', {}],
186
- ['GetInstalledComponents', {}]
187
- ]) {
188
- const result = await client.request('tools/call', { name: `${prefix}${snakeCaseName(tool)}`, arguments: args });
189
- assert.notEqual(result.isError, true, `${tool} returned an MCP tool error: ${textPayload(result)}`);
190
- }
191
-
192
- const query = await client.request('tools/call', {
193
- name: `${prefix}run_query`,
194
- arguments: { sql_query: 'SELECT * FROM T000', max_rows: 1 }
195
- });
196
- assert.notEqual(query.isError, true, `RunQuery returned an MCP tool error: ${textPayload(query)}`);
197
- assert.match(textPayload(query), /T000|MANDT|client|rows|\[/iu);
198
-
199
- const appLog = await client.request('tools/call', {
200
- name: `${prefix}get_application_log`,
201
- arguments: { max_results: 1 }
202
- });
203
- assert.notEqual(appLog.isError, true, `GetApplicationLog returned an MCP tool error: ${textPayload(appLog)}`);
204
- });