sap-ai-dev-toolkit 0.2.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 (57) hide show
  1. package/.github/agents/abap-developer.agent.md +25 -0
  2. package/.github/agents/abap-runtime-debugger.agent.md +27 -0
  3. package/.github/agents/rap-service-developer.agent.md +27 -0
  4. package/.github/agents/sap-solution-architect.agent.md +31 -0
  5. package/.github/skills/abap-debugging/SKILL.md +15 -0
  6. package/.github/skills/abap-development/SKILL.md +16 -0
  7. package/.github/skills/abap-runtime-analysis/SKILL.md +17 -0
  8. package/.github/skills/abap-testing-quality/SKILL.md +19 -0
  9. package/.github/skills/cds-development/SKILL.md +16 -0
  10. package/.github/skills/clean-core-extensibility/SKILL.md +16 -0
  11. package/.github/skills/rap-development/SKILL.md +16 -0
  12. package/.github/skills/rap-service-delivery/SKILL.md +17 -0
  13. package/.github/skills/sap-sdlc-orchestration/SKILL.md +16 -0
  14. package/.github/skills/sap-standard-api-analysis/SKILL.md +16 -0
  15. package/.github/skills/sap-transport-release/SKILL.md +14 -0
  16. package/LICENSE +22 -0
  17. package/LICENSE-APACHE-2.0.txt +201 -0
  18. package/NOTICE +33 -0
  19. package/README.md +723 -0
  20. package/dist/checksums.txt +5 -0
  21. package/dist/vsp-darwin-arm64 +0 -0
  22. package/dist/vsp-darwin-x64 +0 -0
  23. package/dist/vsp-linux-arm64 +0 -0
  24. package/dist/vsp-linux-x64 +0 -0
  25. package/dist/vsp-win32-x64.exe +0 -0
  26. package/package.json +52 -0
  27. package/patches/vsp-bas-proxy-auth.patch +310 -0
  28. package/scripts/build-vsp.mjs +52 -0
  29. package/scripts/ensure-go.mjs +59 -0
  30. package/scripts/install-user-copilot-assets.mjs +110 -0
  31. package/scripts/mutation-terminal-ui.mjs +69 -0
  32. package/scripts/postinstall.mjs +269 -0
  33. package/scripts/publish-npm.mjs +97 -0
  34. package/src/abaplint.mjs +105 -0
  35. package/src/bas-discovery.mjs +210 -0
  36. package/src/binary.mjs +99 -0
  37. package/src/cf-connectivity.mjs +317 -0
  38. package/src/cf-destination.mjs +793 -0
  39. package/src/launcher.mjs +144 -0
  40. package/src/mcp-config.mjs +253 -0
  41. package/src/mcp-proxy.mjs +388 -0
  42. package/src/setup.mjs +275 -0
  43. package/src/terminal-ui.mjs +67 -0
  44. package/test/cf-connectivity.test.mjs +287 -0
  45. package/test/cf-destination.test.mjs +307 -0
  46. package/test/cf-runtime.test.mjs +505 -0
  47. package/test/copilot-assets.test.mjs +37 -0
  48. package/test/copilot-content.test.mjs +145 -0
  49. package/test/discovery.test.mjs +199 -0
  50. package/test/fixtures/fake-vsp.mjs +244 -0
  51. package/test/launcher.test.mjs +164 -0
  52. package/test/mcp-config-cf.test.mjs +149 -0
  53. package/test/mcp-proxy.test.mjs +337 -0
  54. package/test/setup-cf.test.mjs +362 -0
  55. package/test/setup.test.mjs +376 -0
  56. package/test/terminal-ui.test.mjs +47 -0
  57. package/tools.md +68 -0
@@ -0,0 +1,164 @@
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 { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
11
+ const fakeVsp = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
12
+
13
+ function runLauncher(args, env) {
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn(process.execPath, [launcher, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
16
+ let stdout = '';
17
+ let stderr = '';
18
+ child.stdout.on('data', chunk => { stdout += chunk; });
19
+ child.stderr.on('data', chunk => { stderr += chunk; });
20
+ child.on('error', reject);
21
+ child.on('exit', (code, signal) => resolve({ code, signal, stdout, stderr }));
22
+ });
23
+ }
24
+ function runLauncherTty(env, input, args = ['--setup']) {
25
+ return new Promise((resolve, reject) => {
26
+ const command = `${process.execPath} ${launcher} ${args.join(' ')}`;
27
+ const child = spawn('script', ['-qec', command, '/dev/null'], { env, stdio: ['pipe', 'pipe', 'pipe'] });
28
+ let stdout = '';
29
+ let stderr = '';
30
+ let inputSent = false;
31
+ child.stdout.on('data', chunk => {
32
+ stdout += chunk.toString();
33
+ if (!inputSent && stdout.includes('Select destinations')) {
34
+ inputSent = true;
35
+ child.stdin.end(input);
36
+ }
37
+ });
38
+ child.stderr.on('data', chunk => { stderr += chunk.toString(); });
39
+ child.on('error', reject);
40
+ child.on('exit', (code, signal) => resolve({ code, signal, stdout, stderr }));
41
+ });
42
+ }
43
+
44
+ test('help exits before BAS destination discovery', async t => {
45
+ let requests = 0;
46
+ const server = createServer((_request, response) => {
47
+ requests++;
48
+ response.setHeader('content-type', 'application/json');
49
+ response.end('[]');
50
+ });
51
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
52
+ t.after(() => new Promise(resolve => server.close(resolve)));
53
+
54
+ const result = await runLauncher(['--help'], { ...process.env, H2O_URL: `http://127.0.0.1:${server.address().port}`, NO_PROXY: '127.0.0.1' });
55
+ assert.equal(result.code, 0, result.stderr);
56
+ assert.equal(result.stderr, '');
57
+ assert.match(result.stdout, /Usage: sap-ai-dev/);
58
+ assert.match(result.stdout, /Use an MCP client to call server tools/);
59
+ assert.equal(requests, 0);
60
+ });
61
+
62
+ test('list-destinations JSON is redacted and probes through the BAS proxy', async () => {
63
+ const requests = [];
64
+ const server = createServer((request, response) => {
65
+ requests.push({ host: request.headers.host, path: request.url });
66
+ if (request.url === '/api/listDestinations') {
67
+ response.setHeader('content-type', 'application/json');
68
+ response.end(JSON.stringify([{ Name: 'launch-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap', Authentication: 'PrincipalPropagation', Password: 'do-not-print' }]));
69
+ return;
70
+ }
71
+ response.writeHead(403);
72
+ response.end();
73
+ });
74
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
75
+ const port = server.address().port;
76
+ 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' });
77
+ await new Promise(resolve => server.close(resolve));
78
+ assert.equal(result.code, 0, result.stderr);
79
+ assert.deepEqual(JSON.parse(result.stdout), [{ name: 'launch-system', client: '001', authentication: 'PrincipalPropagation', probe: 'auth-required' }]);
80
+ assert.deepEqual(requests, [
81
+ { host: `127.0.0.1:${port}`, path: '/api/listDestinations' },
82
+ { host: 'launch-system.dest', path: 'http://launch-system.dest/sap/bc/adt/discovery' }
83
+ ]);
84
+ assert.equal(result.stdout.includes('do-not-print'), false);
85
+ });
86
+ test('runtime starts destinations even when their ADT probes fail', async () => {
87
+ const server = createServer((request, response) => {
88
+ if (request.url.includes('/api/listDestinations')) {
89
+ response.setHeader('content-type', 'application/json');
90
+ response.end(JSON.stringify([{ Name: 'launch-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap' }]));
91
+ return;
92
+ }
93
+ response.writeHead(503);
94
+ response.end();
95
+ });
96
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
97
+ const proxy = `http://127.0.0.1:${server.address().port}`;
98
+ try {
99
+ const result = await runLauncher([], {
100
+ ...process.env,
101
+ BAS_VSP_BINARY: fakeVsp,
102
+ H2O_URL: 'http://bas.example',
103
+ BAS_VSP_DESTINATION: 'launch-system',
104
+ HTTP_PROXY: proxy,
105
+ http_proxy: proxy,
106
+ NO_PROXY: '',
107
+ no_proxy: ''
108
+ });
109
+ assert.equal(result.code, 0, result.stderr);
110
+ assert.equal(result.stdout, '');
111
+ assert.match(result.stderr, /launch-system: probe=unavailable \(HTTP 503\)/);
112
+ assert.match(result.stderr, /starting MCP proxy for launch-system \(client=001\)/);
113
+ assert.match(result.stderr, /\[launch-system\] starting VSP child/);
114
+ } finally {
115
+ await new Promise(resolve => server.close(resolve));
116
+ }
117
+ });
118
+ test('setup subprocess writes one isolated MCP entry per selected destination', async () => {
119
+ const directory = await mkdtemp(join(tmpdir(), 'bas-launcher-setup-'));
120
+ const config = join(directory, 'mcp.json');
121
+ await writeFile(config, JSON.stringify({ inputs: [], servers: { unrelated: { type: 'stdio', command: 'other' } } }));
122
+ const server = createServer((request, response) => {
123
+ if (request.url === '/api/listDestinations') {
124
+ response.setHeader('content-type', 'application/json');
125
+ response.end(JSON.stringify([
126
+ { Name: 'alpha-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap', 'SAP-Client': '100' },
127
+ { Name: 'beta-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap', 'SAP-Client': '200' }
128
+ ]));
129
+ return;
130
+ }
131
+ response.writeHead(404);
132
+ response.end();
133
+ });
134
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
135
+ const env = { ...process.env, H2O_URL: `http://127.0.0.1:${server.address().port}`, BAS_VSP_SKIP_PROBE: 'true', BAS_VSP_MCP_CONFIG: config };
136
+ try {
137
+ const first = await runLauncherTty(env, 'a\r', ['--setup', '--npx']);
138
+ assert.equal(first.code, 0, `${first.stdout}\n${first.stderr}`);
139
+ let current = JSON.parse(await readFile(config, 'utf8'));
140
+ let generated = Object.entries(current.servers).filter(([, entry]) => entry.BAS_EXT === 'true');
141
+ assert.deepEqual(generated.map(([, entry]) => entry.env.BAS_VSP_DESTINATION), ['alpha-system', 'beta-system'], `${first.stdout}\n${first.stderr}`);
142
+ assert.equal(generated.every(([, entry]) => entry.env.H2O_URL === env.H2O_URL), true);
143
+ const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
144
+ assert.deepEqual(generated.map(([, entry]) => [entry.command, entry.args]), [
145
+ ['npx', ['--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${packageJson.version}`, 'sap-ai-dev']],
146
+ ['npx', ['--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${packageJson.version}`, 'sap-ai-dev']]
147
+ ]);
148
+ const second = await runLauncherTty(env, '\r', ['--setup', '--npx']);
149
+ assert.equal(second.code, 0, `${second.stdout}\\n${second.stderr}`);
150
+ current = JSON.parse(await readFile(config, 'utf8'));
151
+ generated = Object.keys(current.servers).filter(name => name !== 'unrelated');
152
+ assert.deepEqual(generated, []);
153
+ assert.deepEqual(current.servers.unrelated, { type: 'stdio', command: 'other' });
154
+ } finally {
155
+ await new Promise(resolve => server.close(resolve));
156
+ await rm(directory, { recursive: true, force: true });
157
+ }
158
+ });
159
+
160
+ test('non-BAS list mode reports the required discovery boundary', async () => {
161
+ const result = await runLauncher(['--list-destinations', '--json'], { ...process.env, H2O_URL: '' });
162
+ assert.equal(result.code, 1);
163
+ assert.match(result.stderr, /H2O_URL is required/);
164
+ });
@@ -0,0 +1,149 @@
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, installMcpConfig, readMcpConfig } 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
+ BAS_VSP_DESTINATION_SOURCE: 'cloud-foundry',
49
+ BAS_VSP_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.BAS_VSP_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('extracts key references only from package-managed Cloud Foundry entries', () => {
65
+ const cloudFoundry = buildMcpEntries([
66
+ cfDestination('space-one', 'instance-one', 'destination-key', 'OnPremise')
67
+ ], { H2O_URL: 'http://h2o.example' })['cf:space-one:instance-one:shared'];
68
+ const managedNpx = {
69
+ ...cloudFoundry,
70
+ command: 'npx',
71
+ args: ['--yes', '--package=sap-ai-dev-toolkit@0.1.0', 'sap-ai-dev']
72
+ };
73
+ const config = { servers: {
74
+ first: cloudFoundry,
75
+ duplicate: managedNpx,
76
+ unmanaged: { ...cloudFoundry, BAS_EXT: undefined },
77
+ otherPackage: { ...cloudFoundry, command: 'other' },
78
+ otherSource: { ...cloudFoundry, env: { ...cloudFoundry.env, BAS_VSP_DESTINATION_SOURCE: 'bas' } }
79
+ } };
80
+ assert.deepEqual(collectManagedCloudFoundryKeyReferences(config), [
81
+ { kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'destination-key' },
82
+ { kind: 'connectivity', spaceGuid: 'space-one', instanceGuid: 'connectivity-guid', instanceName: 'connectivity-service', keyName: 'connectivity-key' }
83
+ ]);
84
+ const userConfig = { servers: {
85
+ customServer: { command: 'custom-launcher', env: { ...cloudFoundry.env, BAS_VSP_DESTINATION_SOURCE: undefined } },
86
+ noEnvironment: { command: 'custom-launcher' }
87
+ } };
88
+ assert.deepEqual(collectCloudFoundryKeyReferencesFromAllEntries(userConfig), [
89
+ { kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'destination-key' },
90
+ { kind: 'connectivity', spaceGuid: 'space-one', instanceGuid: 'connectivity-guid', instanceName: 'connectivity-service', keyName: 'connectivity-key' }
91
+ ]);
92
+ });
93
+
94
+ test('builds SAP development companion MCP entries', () => {
95
+ const entries = buildSapDevelopmentMcpEntries(['sap-fiori-tools', 'ui5-tools']);
96
+ assert.deepEqual(Object.keys(entries), ['sap-fiori-tools', 'ui5-tools']);
97
+ assert.deepEqual(entries['sap-fiori-tools'].args, ['--yes', '--package=@sap-ux/fiori-mcp-server', 'fiori-mcp']);
98
+ assert.equal(entries['ui5-tools'].command, 'npx');
99
+ assert.equal(entries['ui5-tools'].BAS_EXT_KIND, 'sap-development-companion');
100
+ assert.throws(() => buildSapDevelopmentMcpEntries(['missing-tool']), /Unknown SAP development MCP server id/);
101
+ });
102
+
103
+ test('installs and removes SAP development companion entries', async t => {
104
+ const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-tools-config-'));
105
+ const path = join(directory, 'mcp.json');
106
+ await writeFile(path, JSON.stringify({ servers: { userServer: { type: 'stdio', command: 'custom-server' } } }));
107
+ t.after(() => rm(directory, { recursive: true, force: true }));
108
+
109
+ const result = await installMcpConfig([], {
110
+ env: { H2O_URL: 'http://h2o.example' },
111
+ path,
112
+ sapDevelopmentServers: ['sap-fiori-tools', 'cap-tools']
113
+ });
114
+ assert.deepEqual(Object.keys(result.servers).sort(), ['cap-tools', 'sap-fiori-tools']);
115
+ const written = await readMcpConfig(path);
116
+ assert.deepEqual(Object.keys(written.servers).sort(), ['cap-tools', 'sap-fiori-tools', 'userServer']);
117
+
118
+ await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
119
+ const cleared = await readMcpConfig(path);
120
+ assert.deepEqual(Object.keys(cleared.servers), ['userServer']);
121
+ });
122
+
123
+ test('installs and removes CF entries without deleting unrelated MCP servers', async t => {
124
+ const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-cf-config-'));
125
+ const path = join(directory, 'mcp.json');
126
+ await writeFile(path, JSON.stringify({
127
+ unrelated: true,
128
+ servers: { userServer: { type: 'stdio', command: 'custom-server' } }
129
+ }));
130
+ t.after(() => rm(directory, { recursive: true, force: true }));
131
+
132
+ const result = await installMcpConfig([bas, cfDestination('space-one', 'instance-one', 'managed-key')], {
133
+ env: { H2O_URL: 'http://h2o.example' },
134
+ path,
135
+ command: 'npx',
136
+ args: ['--yes', '--package=sap-ai-dev-toolkit@0.1.0', 'sap-ai-dev']
137
+ });
138
+ assert.deepEqual(Object.keys(result.servers).sort(), ['cf:space-one:instance-one:shared', 'shared']);
139
+ const written = await readMcpConfig(path);
140
+ assert.equal(written.unrelated, true);
141
+ assert.deepEqual(Object.keys(written.servers).sort(), ['cf:space-one:instance-one:shared', 'shared', 'userServer']);
142
+ assert.deepEqual(collectManagedCloudFoundryKeyReferences(written), [
143
+ { kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'managed-key' }
144
+ ]);
145
+
146
+ await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
147
+ const cleared = JSON.parse(await readFile(path, 'utf8'));
148
+ assert.deepEqual(Object.keys(cleared.servers), ['userServer']);
149
+ });
@@ -0,0 +1,337 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { once } from 'node:events';
8
+ import { childArguments, MCPProxy } from '../src/mcp-proxy.mjs';
9
+ import { PassThrough } from 'node:stream';
10
+
11
+ const fixture = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
12
+ const REQUESTED_TOOLS = [
13
+ 'AnalyzeABAPCode', 'AnalyzeCallGraph', 'CodeCompletion', 'GetAbapHelp',
14
+ 'GetCallGraph', 'GetCalleesOf', 'GetCallersOf', 'GetCodeCoverage',
15
+ 'GetConnectionInfo', 'GetObjectStructure', 'GetTypeHierarchy', 'GetTypeInfo',
16
+ 'GrepObject', 'GrepPackage', 'CallRFC', 'DebuggerAttach', 'DebuggerDetach',
17
+ 'DebuggerGetStack', 'DebuggerGetVariables', 'DebuggerListen', 'DebuggerStep',
18
+ 'DeleteBreakpoint', 'GetBreakpoints', 'GetDump', 'GetSQLTraceState', 'GetTrace',
19
+ 'ListDumps', 'SetBreakpoint', 'CloneObject', 'CreateAndActivateProgram',
20
+ 'CreateClassWithTests', 'CreateObject', 'CreateTestInclude', 'DeleteObject',
21
+ 'ExecuteABAP', 'GetClass', 'GetClassComponents', 'GetClassInclude', 'GetFunction',
22
+ 'GetInclude', 'GetInterface', 'GetProgram', 'GetStructure', 'GetTransaction',
23
+ 'LockObject', 'MoveObject', 'RecoverFailedCreate', 'RenameObject', 'SaveToFile',
24
+ 'UnlockObject', 'UpdateClassInclude', 'UpdateSource', 'WriteClass', 'WriteProgram',
25
+ 'CreateTransport', 'ListDependencies', 'PublishServiceBinding', 'UnpublishServiceBinding'
26
+ ];
27
+
28
+
29
+ async function fixtureProxy(logger = () => {}) {
30
+ const directory = await mkdtemp(join(tmpdir(), 'bas-vsp-test-'));
31
+ const log = join(directory, 'children.log');
32
+ const destinations = ['alpha', 'beta'].map(name => ({ name, url: `http://${name}.dest`, client: '001' }));
33
+ const proxy = new MCPProxy({ binary: fixture, destinations, env: { ...process.env, BAS_VSP_DESTINATION: 'alpha', BAS_VSP_MODE: 'expert', FAKE_LOG: log, SAP_ALLOW_TRANSPORTABLE_EDITS: 'true', SAP_USER: 'must-not-pass', SAP_PASSWORD: 'must-not-pass', Authorization: 'Bearer must-not-pass', Cookie: 'secret', SAP_READ_ONLY: 'true' }, log: logger });
34
+ proxy.start();
35
+ return { directory, log, proxy };
36
+ }
37
+ test('uses authentication-specific arguments for Cloud Foundry children', () => {
38
+ const bas = { name: 'bas', url: 'http://bas.dest', client: '001' };
39
+ assert.deepEqual(childArguments(bas, {}), [
40
+ '--url', 'http://bas.dest', '--client', '001', '--mode', 'expert', '--proxy-auth', '--enable-transports'
41
+ ]);
42
+ const noAuth = { source: 'cloud-foundry', name: 'internet', url: 'http://sap.example', client: '100', authentication: 'NoAuthentication' };
43
+ const basic = { ...noAuth, authentication: 'BasicAuthentication' };
44
+ const principal = { ...noAuth, proxyType: 'OnPremise', authentication: 'PrincipalPropagation' };
45
+ assert.equal(childArguments(noAuth, {}).includes('--proxy-auth'), false);
46
+ assert.equal(childArguments(basic, {}).includes('--proxy-auth'), false);
47
+ assert.equal(childArguments(principal, {}).includes('--proxy-auth'), true);
48
+ });
49
+
50
+ test('closes each Cloud Foundry route after child exit and on startup failure', async t => {
51
+ const directory = await mkdtemp(join(tmpdir(), 'bas-cf-child-lifecycle-'));
52
+ const log = join(directory, 'children.log');
53
+ let closeCount = 0;
54
+ const destination = {
55
+ source: 'cloud-foundry',
56
+ name: 'runtime',
57
+ url: 'http://runtime.dest',
58
+ client: '001',
59
+ authentication: 'BasicAuthentication',
60
+ childEnv: { SAP_USER: 'destination-user', SAP_PASSWORD: 'destination-password', SAP_VERBOSE: 'false' },
61
+ async close() { closeCount += 1; }
62
+ };
63
+ const proxy = new MCPProxy({
64
+ binary: fixture,
65
+ destinations: [destination],
66
+ env: { ...process.env, FAKE_LOG: log, SAP_USER: 'parent-user', SAP_PASSWORD: 'parent-password' },
67
+ log: () => {}
68
+ });
69
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
70
+ proxy.start();
71
+ const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
72
+ assert.ok(initialized.result);
73
+ const child = proxy.children[0].child;
74
+ const exit = once(child.process, 'exit');
75
+ child.process.kill('SIGTERM');
76
+ await exit;
77
+ await new Promise(resolve => setImmediate(resolve));
78
+ assert.equal(closeCount, 1);
79
+ const event = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).find(row => row.event === 'initialize');
80
+ assert.equal(event.argv.includes('--proxy-auth'), false);
81
+ assert.deepEqual([event.env.user, event.env.password, event.env.verbose], ['destination-user', 'destination-password', 'false']);
82
+ await proxy.close();
83
+ assert.equal(closeCount, 1);
84
+
85
+ let failedStartCloseCount = 0;
86
+ const failedProxy = new MCPProxy({
87
+ binary: fixture,
88
+ destinations: [{ ...destination, async close() { failedStartCloseCount += 1; } }],
89
+ spawn: () => { throw new Error('spawn denied'); },
90
+ log: () => {}
91
+ });
92
+ assert.throws(() => failedProxy.start(), /No destination child could be started/);
93
+ await new Promise(resolve => setImmediate(resolve));
94
+ assert.equal(failedStartCloseCount, 1);
95
+ });
96
+
97
+ test('merges paged tools and routes calls to the selected child', async t => {
98
+ const { directory, log, proxy } = await fixtureProxy();
99
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
100
+
101
+ const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
102
+ assert.equal(initialized.result.serverInfo.name, 'alpha');
103
+ const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
104
+ const names = new Set(listed.result.tools.map(tool => tool.name));
105
+ const queryTool = listed.result.tools.find(tool => tool.name === 'beta__RunQuery');
106
+ assert.deepEqual(queryTool.inputSchema, {
107
+ type: 'object',
108
+ properties: { sql_query: { type: 'string' }, max_rows: { type: 'number' }, all_rows: { type: 'boolean' } },
109
+ required: ['sql_query']
110
+ });
111
+ assert.equal(queryTool.description, 'RunQuery [destination: beta]');
112
+ const lintTool = listed.result.tools.find(tool => tool.name === 'alpha__LintABAP');
113
+ assert.deepEqual(Object.keys(lintTool.inputSchema.properties).sort(), ['config', 'files']);
114
+ assert.deepEqual(lintTool.inputSchema.required, ['files']);
115
+ assert.deepEqual(lintTool.inputSchema.properties.files.items.required, ['filename', 'source']);
116
+
117
+ for (const destination of ['alpha', 'beta']) {
118
+ for (const toolName of REQUESTED_TOOLS) {
119
+ assert.ok(names.has(`${destination}__${toolName}`), `${destination} exposes ${toolName}`);
120
+ }
121
+ for (const coreTool of [
122
+ 'GetSource', 'RunQuery', 'GetSystemInfo', 'GetInstalledComponents',
123
+ 'GetFeatures', 'GetAPIReleaseState', 'PrettyPrint', 'ListTransports',
124
+ 'GetTransport', 'GetUserTransports', 'GetTransportInfo',
125
+ 'GetApplicationLog', 'ActivateMultiple'
126
+ ]) {
127
+ assert.ok(names.has(`${destination}__${coreTool}`), `${destination} exposes ${coreTool}`);
128
+ }
129
+ for (const formerlyExcludedTool of ['ListSQLTraces', 'ReleaseTransport', 'DeleteTransport', 'SAP']) {
130
+ assert.ok(names.has(`${destination}__${formerlyExcludedTool}`), `${destination} exposes ${formerlyExcludedTool}`);
131
+ }
132
+ }
133
+
134
+ const requiredArguments = {
135
+ GetFeatures: [],
136
+ GetAPIReleaseState: ['object_uri'],
137
+ PrettyPrint: ['source'],
138
+ ListTransports: [],
139
+ GetTransport: ['transport'],
140
+ GetUserTransports: [],
141
+ GetTransportInfo: ['object_url', 'dev_class'],
142
+ ActivateMultiple: ['objects'],
143
+ GetApplicationLog: []
144
+ };
145
+ for (const [toolName, required] of Object.entries(requiredArguments)) {
146
+ const tool = listed.result.tools.find(entry => entry.name === `alpha__${toolName}`);
147
+ assert.deepEqual(tool.inputSchema.required, required);
148
+ }
149
+ const applicationLog = listed.result.tools.find(entry => entry.name === 'alpha__GetApplicationLog');
150
+ assert.deepEqual(Object.keys(applicationLog.inputSchema.properties).sort(), [
151
+ 'from', 'max_results', 'messages', 'object', 'program', 'subobject', 'to', 'user'
152
+ ]);
153
+
154
+ const read = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'beta__GetSource', arguments: { object: 'ZREAD' } } });
155
+ const write = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'alpha__WriteSource', arguments: { source: 'WRITE' } } });
156
+ const query = await proxy.handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'beta__RunQuery', arguments: { sql_query: 'SELECT * FROM T000' } } });
157
+ const system = await proxy.handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'alpha__GetSystemInfo', arguments: {} } });
158
+ assert.equal(read.result.content[0].text, 'beta:GetSource');
159
+ assert.equal(write.result.content[0].text, 'alpha:WriteSource');
160
+ assert.equal(query.result.content[0].text, 'beta:RunQuery');
161
+ assert.equal(system.result.content[0].text, 'alpha:GetSystemInfo');
162
+
163
+ const additionalCalls = [
164
+ ['GetFeatures', {}],
165
+ ['GetAPIReleaseState', { object_uri: '/sap/bc/adt/oo/classes/cl_abap_typedescr' }],
166
+ ['PrettyPrint', { source: 'WRITE / 1.' }],
167
+ ['ListTransports', {}],
168
+ ['GetTransport', { transport: 'A4HK900094' }],
169
+ ['GetUserTransports', { user_name: 'DEVUSER' }],
170
+ ['GetTransportInfo', { object_url: '/sap/bc/adt/oo/classes/zcl_demo', dev_class: 'ZPKG' }],
171
+ ['GetApplicationLog', {
172
+ program: 'ZDEMO_POST',
173
+ user: 'TESTUSER',
174
+ object: 'ZDEMO_LOG',
175
+ subobject: 'POST',
176
+ from: '2026-08-01',
177
+ to: '2026-08-31',
178
+ max_results: 20,
179
+ messages: true,
180
+ action: 'delete',
181
+ type: 'delete',
182
+ params: { type: 'delete' }
183
+ }],
184
+ ['ActivateMultiple', { objects: ['PROG ZDEMO'] }],
185
+ ['CreateTransport', {}],
186
+ ['ReleaseTransport', {}],
187
+ ['DeleteTransport', {}],
188
+ ['ListSQLTraces', {}],
189
+ ['SAP', { action: 'analyze', params: { type: 'application_log' } }]
190
+ ];
191
+ for (const [index, [name, arguments_]] of additionalCalls.entries()) {
192
+ const response = await proxy.handle({
193
+ jsonrpc: '2.0',
194
+ id: 7 + index,
195
+ method: 'tools/call',
196
+ params: { name: `alpha__${name}`, arguments: arguments_ }
197
+ });
198
+ assert.equal(response.result.content[0].text, name === 'GetApplicationLog' ? 'alpha:SAP' : `alpha:${name}`);
199
+ }
200
+
201
+ const entries = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
202
+ const calls = entries.filter(entry => entry.event === 'call');
203
+ assert.deepEqual(calls.map(call => [call.destination, call.name]), [
204
+ ['beta', 'GetSource'], ['alpha', 'WriteSource'], ['beta', 'RunQuery'], ['alpha', 'GetSystemInfo'],
205
+ ...additionalCalls.map(([name]) => ['alpha', name === 'GetApplicationLog' ? 'SAP' : name])
206
+ ]);
207
+ assert.deepEqual(calls.map(call => call.arguments), [
208
+ { object: 'ZREAD' }, { source: 'WRITE' }, { sql_query: 'SELECT * FROM T000' }, {},
209
+ ...additionalCalls.map(([name, arguments_]) => name === 'GetApplicationLog'
210
+ ? {
211
+ action: 'analyze',
212
+ params: {
213
+ type: 'application_log',
214
+ program: arguments_.program,
215
+ user: arguments_.user,
216
+ object: arguments_.object,
217
+ subobject: arguments_.subobject,
218
+ from: arguments_.from,
219
+ to: arguments_.to,
220
+ max_results: arguments_.max_results,
221
+ messages: arguments_.messages
222
+ }
223
+ }
224
+ : arguments_)
225
+ ]);
226
+
227
+ for (const init of entries.filter(entry => entry.event === 'initialize')) {
228
+ assert.equal(init.env.guard, 'true');
229
+ assert.equal(init.env.authorization, undefined);
230
+ assert.equal(init.env.cookie, undefined);
231
+ assert.equal(init.env.user, undefined);
232
+ assert.equal(init.env.password, undefined);
233
+ assert.equal(init.env.allowTransportableEdits, 'true');
234
+ }
235
+ });
236
+ test('runs LintABAP locally and returns parser findings', async t => {
237
+ const { directory, log, proxy } = await fixtureProxy();
238
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
239
+
240
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
241
+ const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
242
+ assert.ok(listed.result.tools.some(tool => tool.name === 'alpha__LintABAP'));
243
+ const called = await proxy.handle({
244
+ jsonrpc: '2.0',
245
+ id: 3,
246
+ method: 'tools/call',
247
+ params: {
248
+ name: 'alpha__LintABAP',
249
+ arguments: { files: [{ filename: 'zparser_error.prog.abap', source: 'blah blah.' }] }
250
+ }
251
+ });
252
+ assert.equal(called.result.isError, false);
253
+ const report = JSON.parse(called.result.content[0].text);
254
+ assert.equal(report.status, 'issues');
255
+ assert.ok(report.issues.some(issue =>
256
+ issue.rule === 'parser_error' &&
257
+ issue.filename === 'zparser_error.prog.abap' &&
258
+ issue.start.line === 1
259
+ ));
260
+
261
+ const entries = (await readFile(log, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
262
+ assert.equal(entries.some(entry => entry.event === 'call' && entry.name === 'LintABAP'), false);
263
+ });
264
+ test('exposes local lint when a destination tool listing fails', async t => {
265
+ const { directory, proxy } = await fixtureProxy();
266
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
267
+
268
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
269
+ const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
270
+ alpha.child.listTools = async () => { throw new Error('fixture tools/list failure'); };
271
+
272
+ const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
273
+ assert.ok(listed.result.tools.some(tool => tool.name === 'alpha__LintABAP'));
274
+ assert.equal(listed.result.tools.some(tool => tool.name === 'alpha__GetSource'), false);
275
+ const called = await proxy.handle({
276
+ jsonrpc: '2.0',
277
+ id: 3,
278
+ method: 'tools/call',
279
+ params: { name: 'alpha__LintABAP', arguments: { files: [{ filename: 'zparser_error.prog.abap', source: 'blah blah.' }] } }
280
+ });
281
+ assert.equal(called.result.isError, false);
282
+ });
283
+
284
+ test('returns JSON-RPC errors for unknown namespaces and logs child failures', async t => {
285
+ const logs = [];
286
+ const { directory, proxy } = await fixtureProxy(message => logs.push(message));
287
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
288
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
289
+ await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
290
+ const unknown = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'missing__GetSource' } });
291
+ assert.equal(unknown.error.code, -32602);
292
+ const remoteError = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'alpha__GetSource', arguments: { object: 'RPC_ERROR' } } });
293
+ assert.equal(remoteError.error.code, -32042);
294
+ assert.ok(logs.some(message => message.includes('backend connection refused') && message.includes('password=[redacted]') && !message.includes('must-not-log')));
295
+ const toolError = await proxy.handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'beta__GetSource', arguments: { object: 'TOOL_ERROR' } } });
296
+ assert.equal(toolError.result.isError, true);
297
+ assert.ok(logs.some(message => message.includes('backend timeout') && message.includes('token=[redacted]') && !message.includes('must-not-log')));
298
+ const success = await proxy.handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'alpha__WriteSource', arguments: { source: 'must-not-log' } } });
299
+ assert.equal(success.result.content[0].text, 'alpha:WriteSource');
300
+ assert.ok(logs.some(message => /\[alpha\] tools\/call WriteSource completed in \d+ms/.test(message)));
301
+ assert.equal(logs.some(message => message.includes('must-not-log')), false);
302
+ const child = proxy.children.find(entry => entry.destination.name === 'alpha').child;
303
+ child.process.kill('SIGKILL');
304
+ await new Promise(resolve => setTimeout(resolve, 25));
305
+ const failed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'alpha__GetSource' } });
306
+ assert.equal(failed.error.code, -32001);
307
+ assert.ok(logs.some(message => message.includes('alpha') && message.includes('tools/call GetSource failed')));
308
+ });
309
+ test('stdin EOF shuts down every child process and forwards child logs', async t => {
310
+ const { directory, log, proxy } = await fixtureProxy();
311
+ t.after(async () => { await rm(directory, { recursive: true, force: true }); });
312
+ const input = new PassThrough();
313
+ const output = [];
314
+ const serving = proxy.serve(input, line => output.push(JSON.parse(line)));
315
+ input.end([
316
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
317
+ { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
318
+ { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'alpha__GetSource', arguments: { emitNotification: true } } }
319
+ ].map(message => JSON.stringify(message)).join('\n') + '\n');
320
+ await serving;
321
+ assert.ok(output.some(message => message.method === 'notifications/message' && message.params?.data === 'fixture log notification'));
322
+ const entries = (await readFile(log, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
323
+ assert.equal(entries.filter(entry => entry.event === 'initialize').length, 2);
324
+ assert.deepEqual([...new Set(entries.filter(entry => entry.event === 'term').map(entry => entry.destination))].sort(), ['alpha', 'beta']);
325
+ });
326
+
327
+
328
+ test('keeps healthy children when one destination fails initialization', async t => {
329
+ const directory = await mkdtemp(join(tmpdir(), 'bas-vsp-test-'));
330
+ const log = join(directory, 'children.log');
331
+ const proxy = new MCPProxy({ binary: fixture, destinations: ['broken', 'healthy'].map(name => ({ name, url: `http://${name}.dest`, client: '001' })), env: { ...process.env, FAKE_LOG: log }, log: () => {} });
332
+ proxy.start();
333
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
334
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
335
+ const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
336
+ assert.ok(listed.result.tools.every(tool => tool.name.startsWith('healthy__')));
337
+ });