sap-ai-dev-toolkit 0.5.0 → 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 +17 -11
  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 +17 -18
  25. package/src/bas-destination-relay.mjs +58 -22
  26. package/src/bas-discovery.mjs +24 -8
  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 +22 -19
  33. package/src/mcp-config.mjs +41 -7
  34. package/src/mcp-proxy.mjs +392 -104
  35. package/src/redact.mjs +28 -0
  36. package/src/setup.mjs +33 -3
  37. package/tools.md +4 -2
  38. package/test/bas-destination-relay.test.mjs +0 -374
  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 -257
  46. package/test/fixtures/fake-vsp.mjs +0 -263
  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 -399
  51. package/test/live-s4h.test.mjs +0 -202
  52. package/test/mcp-config-cf.test.mjs +0 -407
  53. package/test/mcp-proxy.test.mjs +0 -409
  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,44 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { fileURLToPath } from 'node:url';
4
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
-
7
- const serverScript = fileURLToPath(new URL('../src/hana-inspector.mjs', import.meta.url));
8
-
9
- test('standalone HANA MCP process lists tools through stdio without emitting secrets or diagnostics on stdout', async t => {
10
- const transport = new StdioClientTransport({
11
- command: process.execPath,
12
- args: [serverScript],
13
- cwd: process.cwd(),
14
- env: {
15
- PATH: process.env.PATH || '',
16
- HANA_RO_HOST: 'hana.example.test',
17
- HANA_RO_PORT: '443',
18
- HANA_RO_USER: 'INSPECTOR',
19
- HANA_RO_PASSWORD: 'never-emit-this-secret',
20
- HANA_RO_SCHEMA: 'HDI_SCHEMA'
21
- },
22
- stderr: 'pipe'
23
- });
24
- const client = new Client({ name: 'hana-stdio-smoke', version: '1.0.0' });
25
- const stderr = transport.stderr;
26
- let stderrText = '';
27
- stderr?.setEncoding('utf8');
28
- stderr?.on('data', chunk => { stderrText += chunk; });
29
- t.after(async () => {
30
- await client.close().catch(() => {});
31
- await transport.close().catch(() => {});
32
- });
33
-
34
- await client.connect(transport);
35
- const listed = await client.listTools();
36
- assert.deepEqual(listed.tools.map(tool => tool.name).sort(), [
37
- 'hana_connection_info',
38
- 'hana_describe_object',
39
- 'hana_list_objects',
40
- 'hana_read_rows'
41
- ]);
42
- assert.equal(stderrText.includes('never-emit-this-secret'), false);
43
- assert.equal(stderrText.includes('HANA_RO_PASSWORD='), false);
44
- });
@@ -1,206 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
- import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
5
- import { createHanaInspector } from '../src/hana-inspector.mjs';
6
-
7
- const env = {
8
- HANA_RO_HOST: 'hana.example.test',
9
- HANA_RO_PORT: '443',
10
- HANA_RO_USER: 'INSPECTOR',
11
- HANA_RO_PASSWORD: 'test-secret',
12
- HANA_RO_SCHEMA: 'HDI_SCHEMA'
13
- };
14
-
15
- function fakeDatabase({ rowResult = [{ ID: 7, TITLE: 'Catalog item' }], columns: columnOverride } = {}) {
16
- const calls = [];
17
- let closed = false;
18
- const columns = columnOverride || [
19
- { COLUMN_NAME: 'ID', POSITION: 1, DATA_TYPE_NAME: 'INTEGER', LENGTH: 10, SCALE: 0, IS_NULLABLE: 'FALSE' },
20
- { COLUMN_NAME: 'TITLE', POSITION: 2, DATA_TYPE_NAME: 'NVARCHAR', LENGTH: 80, SCALE: 0, IS_NULLABLE: 'TRUE' }
21
- ];
22
- return {
23
- calls,
24
- get closed() { return closed; },
25
- async query(sql, values = []) {
26
- calls.push({ sql, values });
27
- if (sql.includes('CURRENT_SCHEMA')) return [{ CURRENT_SCHEMA: 'HDI_SCHEMA', CURRENT_USER: 'INSPECTOR' }];
28
- if (sql.includes('FROM SYS.TABLES') && sql.includes('TABLE_NAME = ?')) {
29
- return values[1] === 'BOOKS' ? [{ OBJECT_NAME: 'BOOKS' }] : [];
30
- }
31
- if (sql.includes('FROM SYS.VIEWS') && sql.includes('VIEW_NAME = ?')) {
32
- return values[1] === 'BOOKS_VIEW' ? [{ OBJECT_NAME: 'BOOKS_VIEW' }] : [];
33
- }
34
- if (sql.includes('FROM SYS.TABLES') && sql.includes('ORDER BY TABLE_NAME')) return [{ OBJECT_NAME: 'BOOKS' }];
35
- if (sql.includes('FROM SYS.VIEWS') && sql.includes('ORDER BY VIEW_NAME')) return [{ OBJECT_NAME: 'BOOKS_VIEW' }];
36
- if (sql.includes('FROM SYS.TABLE_COLUMNS')) return columns;
37
- if (sql.includes('FROM SYS.VIEW_COLUMNS')) return columns;
38
- if (sql.startsWith('SELECT "')) return rowResult;
39
- throw new Error(`Unexpected test SQL: ${sql}`);
40
- },
41
- async close() { closed = true; }
42
- };
43
- }
44
-
45
- async function connectTestClient(t, database) {
46
- const runtime = createHanaInspector({ env, databaseFactory: async () => database });
47
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
48
- const client = new Client({ name: 'hana-test-client', version: '1.0.0' });
49
- await runtime.server.connect(serverTransport);
50
- await client.connect(clientTransport);
51
- t.after(async () => {
52
- await client.close().catch(() => {});
53
- await runtime.close();
54
- });
55
- return { client, runtime };
56
- }
57
-
58
- function parseResult(response) {
59
- assert.equal(response.isError, undefined, response.content?.[0]?.text);
60
- return JSON.parse(response.content[0].text);
61
- }
62
-
63
- test('advertises only bounded, read-only HANA tools with no arbitrary SQL input', async t => {
64
- const database = fakeDatabase();
65
- const { client } = await connectTestClient(t, database);
66
- const result = await client.listTools();
67
- const tools = new Map(result.tools.map(tool => [tool.name, tool]));
68
- assert.deepEqual([...tools.keys()].sort(), [
69
- 'hana_connection_info',
70
- 'hana_describe_object',
71
- 'hana_list_objects',
72
- 'hana_read_rows'
73
- ]);
74
- for (const tool of tools.values()) {
75
- assert.equal(tool.annotations?.readOnlyHint, true);
76
- assert.equal(tool.annotations?.destructiveHint, false);
77
- }
78
- const readRowsSchema = tools.get('hana_read_rows').inputSchema;
79
- assert.ok(readRowsSchema.properties.objectName);
80
- assert.ok(readRowsSchema.properties.columns);
81
- assert.equal(readRowsSchema.properties.sql, undefined);
82
- assert.equal(readRowsSchema.properties.schema, undefined);
83
- });
84
-
85
- test('returns connection identity and schema-scoped object/column metadata', async t => {
86
- const database = fakeDatabase();
87
- const { client } = await connectTestClient(t, database);
88
-
89
- const identity = parseResult(await client.callTool({ name: 'hana_connection_info', arguments: {} }));
90
- assert.equal(identity.currentSchema, 'HDI_SCHEMA');
91
- assert.equal(identity.databaseUser, 'INSPECTOR');
92
- assert.equal(identity.tls, 'enabled; certificate verification required');
93
- assert.equal(JSON.stringify(identity).includes('test-secret'), false);
94
-
95
- const objects = parseResult(await client.callTool({ name: 'hana_list_objects', arguments: { objectType: 'ALL', maxResults: 10 } }));
96
- assert.deepEqual(objects.objects, [
97
- { objectName: 'BOOKS', objectType: 'TABLE' },
98
- { objectName: 'BOOKS_VIEW', objectType: 'VIEW' }
99
- ]);
100
- assert.ok(database.calls.every(call => call.values.every(value => value !== 'OTHER_SCHEMA')));
101
-
102
- const description = parseResult(await client.callTool({ name: 'hana_describe_object', arguments: { objectName: 'BOOKS' } }));
103
- assert.equal(description.schema, 'HDI_SCHEMA');
104
- assert.deepEqual(description.columns.map(column => column.COLUMN_NAME), ['ID', 'TITLE']);
105
- });
106
-
107
- test('reads only catalog-verified columns with bound filters and a fixed HDI schema', async t => {
108
- const database = fakeDatabase();
109
- const { client } = await connectTestClient(t, database);
110
- const response = parseResult(await client.callTool({
111
- name: 'hana_read_rows',
112
- arguments: {
113
- objectName: 'BOOKS',
114
- schema: 'OTHER_SCHEMA',
115
- columns: ['ID', 'TITLE'],
116
- filters: [{ column: 'ID', operator: 'gt', value: 4 }],
117
- orderBy: { column: 'ID', direction: 'ASC' },
118
- limit: 10
119
- }
120
- }));
121
- assert.deepEqual(response.rows, [{ ID: 7, TITLE: 'Catalog item' }]);
122
- const dataQuery = database.calls.find(call => call.sql.startsWith('SELECT "ID"'));
123
- assert.ok(dataQuery);
124
- assert.match(dataQuery.sql, /FROM "HDI_SCHEMA"\."BOOKS"/);
125
- assert.doesNotMatch(dataQuery.sql, /OTHER_SCHEMA/);
126
- assert.deepEqual(dataQuery.values, [4]);
127
- assert.match(dataQuery.sql, /LIMIT 10$/);
128
- });
129
-
130
- test('binds hostile object names and rejects unknown columns without executing a data query', async t => {
131
- const database = fakeDatabase();
132
- const { client } = await connectTestClient(t, database);
133
- const maliciousName = 'BOOKS"; DROP TABLE X;--';
134
- const response = await client.callTool({
135
- name: 'hana_read_rows',
136
- arguments: { objectName: maliciousName, columns: ['ID'], limit: 1 }
137
- });
138
- assert.equal(response.isError, true);
139
- assert.equal(database.calls.some(call => call.sql.includes('DROP TABLE')), false);
140
- const lookup = database.calls.find(call => call.sql.includes('TABLE_NAME = ?'));
141
- assert.deepEqual(lookup.values, ['HDI_SCHEMA', maliciousName]);
142
-
143
- const invalidColumn = await client.callTool({
144
- name: 'hana_read_rows',
145
- arguments: { objectName: 'BOOKS', columns: ['SECRET'], limit: 1 }
146
- });
147
- assert.equal(invalidColumn.isError, true);
148
- assert.equal(database.calls.some(call => call.sql.startsWith('SELECT "SECRET"')), false);
149
- });
150
-
151
- test('bounds object and row limits and refuses oversized results', async t => {
152
- const largeDatabase = fakeDatabase({ rowResult: [{ ID: 1, TITLE: 'x'.repeat(100_000) }] });
153
- const { client } = await connectTestClient(t, largeDatabase);
154
- const tooManyObjects = await client.callTool({
155
- name: 'hana_list_objects',
156
- arguments: { objectType: 'TABLE', maxResults: 201 }
157
- });
158
- assert.equal(tooManyObjects.isError, true);
159
-
160
- const tooLarge = await client.callTool({
161
- name: 'hana_read_rows',
162
- arguments: { objectName: 'BOOKS', columns: ['ID', 'TITLE'], limit: 1 }
163
- });
164
- assert.equal(tooLarge.isError, true);
165
- assert.match(tooLarge.content[0].text, /too large to return safely/);
166
- assert.ok(Buffer.byteLength(tooLarge.content[0].text) < 1000);
167
- });
168
-
169
- test('rejects unbounded or oversized column selections before fetching row data', async t => {
170
- const database = fakeDatabase({ columns: [
171
- { COLUMN_NAME: 'ID', POSITION: 1, DATA_TYPE_NAME: 'INTEGER', LENGTH: 10, SCALE: 0, IS_NULLABLE: 'FALSE' },
172
- { COLUMN_NAME: 'BODY', POSITION: 2, DATA_TYPE_NAME: 'NCLOB', LENGTH: 0, SCALE: 0, IS_NULLABLE: 'TRUE' },
173
- { COLUMN_NAME: 'WIDE', POSITION: 3, DATA_TYPE_NAME: 'NVARCHAR', LENGTH: 9000, SCALE: 0, IS_NULLABLE: 'TRUE' },
174
- { COLUMN_NAME: 'API_KEY', POSITION: 4, DATA_TYPE_NAME: 'NVARCHAR', LENGTH: 128, SCALE: 0, IS_NULLABLE: 'TRUE' }
175
- ] });
176
- const { client } = await connectTestClient(t, database);
177
-
178
- const lob = await client.callTool({
179
- name: 'hana_read_rows',
180
- arguments: { objectName: 'BOOKS', columns: ['BODY'], limit: 1 }
181
- });
182
- assert.equal(lob.isError, true);
183
- assert.match(lob.content[0].text, /LOB and binary columns are not supported/);
184
-
185
- const oversized = await client.callTool({
186
- name: 'hana_read_rows',
187
- arguments: { objectName: 'BOOKS', columns: ['WIDE'], limit: 1 }
188
- });
189
- assert.equal(oversized.isError, true);
190
- assert.match(oversized.content[0].text, /safe row-width limit/);
191
- const credential = await client.callTool({
192
- name: 'hana_read_rows',
193
- arguments: { objectName: 'BOOKS', columns: ['API_KEY'], limit: 1 }
194
- });
195
- assert.equal(credential.isError, true);
196
- assert.match(credential.content[0].text, /Credential-like columns/);
197
- assert.equal(database.calls.some(call => ['BODY', 'WIDE', 'API_KEY'].some(name => call.sql.startsWith(`SELECT "${name}"`))), false);
198
- });
199
-
200
- test('closes the connected database when the server runtime closes', async t => {
201
- const database = fakeDatabase();
202
- const { client, runtime } = await connectTestClient(t, database);
203
- await client.callTool({ name: 'hana_connection_info', arguments: {} });
204
- await runtime.close();
205
- assert.equal(database.closed, true);
206
- });
@@ -1,399 +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/datapreview/freestyle') {
173
- if (request.method === 'GET' && request.headers['x-csrf-token'] === 'Fetch') {
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 (request.method === 'POST'
182
- && request.headers['x-csrf-token'] === 'doctor-token'
183
- && String(request.headers.cookie).includes('sap-session=doctor-session')) {
184
- response.writeHead(200);
185
- response.end('<written/>');
186
- return;
187
- }
188
- response.writeHead(403, { 'x-csrf-token': 'Required' });
189
- response.end('CSRF token validation failed');
190
- return;
191
- }
192
- response.writeHead(200);
193
- response.end('<ok/>');
194
- });
195
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
196
- t.after(async () => {
197
- await new Promise(resolve => server.close(resolve));
198
- await rm(directory, { recursive: true, force: true });
199
- });
200
- const proxy = `http://127.0.0.1:${server.address().port}`;
201
- const result = await runLauncher(['--doctor', '--json'], {
202
- ...process.env,
203
- SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
204
- FAKE_RELAY_CSRF_TEST: 'true',
205
- SAP_AI_DEV_MCP_CONFIG: configPath,
206
- SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system',
207
- H2O_URL: `http://127.0.0.1:${server.address().port}`,
208
- HTTP_PROXY: proxy,
209
- http_proxy: proxy,
210
- NO_PROXY: '127.0.0.1,localhost',
211
- no_proxy: '127.0.0.1,localhost'
212
- });
213
- assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`);
214
- const report = JSON.parse(result.stdout);
215
- assert.equal(report.ok, true);
216
- assert.ok(report.checks.some(check => check.stage === 'BAS relay CSRF preflight' && check.status === 'passed'));
217
- assert.ok(report.checks.some(check => check.stage === 'SAP system check' && check.status === 'passed'));
218
- assert.ok(report.checks.some(check => check.stage === 'MCP config repair' && check.status === 'passed' && /1 managed BAS entry repaired/.test(check.detail)));
219
- assert.ok(report.checks.some(check => check.stage === 'MCP tools/list' && /total MCP tools returned; chat-picker binding is host-managed/.test(check.detail)));
220
- assert.ok(requests.some(request => request.method === 'GET' && request.path === '/sap/bc/adt/datapreview/freestyle' && request.csrf === 'Fetch'));
221
- 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')));
222
- const repairedConfig = JSON.parse(await readFile(configPath, 'utf8'));
223
- assert.equal(repairedConfig.servers['doctor-system'].env.H2O_URL, `http://127.0.0.1:${server.address().port}`);
224
- assert.deepEqual(repairedConfig.servers.ActionS4D_100, config.servers.ActionS4D_100);
225
- });
226
-
227
- test('doctor stops before GetSystemInfo when the runtime CSRF preflight has no session cookie', async t => {
228
- const directory = await mkdtemp(join(tmpdir(), 'bas-doctor-csrf-missing-cookie-'));
229
- const configPath = join(directory, 'mcp.json');
230
- await writeFile(configPath, JSON.stringify({ servers: {} }));
231
- const requests = [];
232
- const server = createServer((request, response) => {
233
- let requestPath;
234
- try { requestPath = new URL(request.url, `http://${request.headers.host}`).pathname; }
235
- catch { requestPath = request.url; }
236
- requests.push({ method: request.method, path: requestPath, csrf: request.headers['x-csrf-token'] });
237
- if (request.url === '/api/listDestinations') {
238
- response.setHeader('content-type', 'application/json');
239
- response.end(JSON.stringify([{
240
- Name: 'doctor-system',
241
- WebIDEEnabled: true,
242
- 'HTML5.DynamicDestination': true,
243
- WebIDEUsage: 'dev_abap',
244
- Authentication: 'BasicAuthentication'
245
- }]));
246
- return;
247
- }
248
- if (requestPath === '/sap/bc/adt/datapreview/freestyle') {
249
- response.writeHead(200, { 'x-csrf-token': 'orphan-token' });
250
- response.end('<ok/>');
251
- return;
252
- }
253
- response.writeHead(200);
254
- response.end('<ok/>');
255
- });
256
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
257
- t.after(async () => {
258
- await new Promise(resolve => server.close(resolve));
259
- await rm(directory, { recursive: true, force: true });
260
- });
261
- const proxy = `http://127.0.0.1:${server.address().port}`;
262
- const result = await runLauncher(['--doctor', '--json'], {
263
- ...process.env,
264
- SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
265
- FAKE_RELAY_CSRF_TEST: 'true',
266
- SAP_AI_DEV_MCP_CONFIG: configPath,
267
- SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system',
268
- H2O_URL: `http://127.0.0.1:${server.address().port}`,
269
- HTTP_PROXY: proxy,
270
- http_proxy: proxy,
271
- NO_PROXY: '127.0.0.1,localhost',
272
- no_proxy: '127.0.0.1,localhost'
273
- });
274
- assert.equal(result.code, 1);
275
- const report = JSON.parse(result.stdout);
276
- assert.equal(report.ok, false);
277
- assert.ok(report.checks.some(check => check.stage === 'BAS relay CSRF preflight' && check.status === 'failed' && /cookies=0; usableSession=false/.test(check.detail)));
278
- assert.ok(report.checks.some(check => check.stage === 'SAP system check' && check.status === 'skipped' && /validated BAS proxy tunnel credential override/.test(check.detail)));
279
- assert.equal(requests.some(request => request.method === 'GET' && request.path === '/sap/bc/adt/datapreview/freestyle' && request.csrf === 'Fetch'), true);
280
- 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');
281
- });
282
-
283
- test('list-destinations JSON is redacted and probes through the BAS proxy', async () => {
284
- const requests = [];
285
- const server = createServer((request, response) => {
286
- requests.push({ host: request.headers.host, path: request.url });
287
- if (request.url === '/api/listDestinations') {
288
- response.setHeader('content-type', 'application/json');
289
- response.end(JSON.stringify([{ Name: 'launch-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap', Authentication: 'PrincipalPropagation', Password: 'do-not-print' }]));
290
- return;
291
- }
292
- response.writeHead(403);
293
- response.end();
294
- });
295
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
296
- const port = server.address().port;
297
- 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' });
298
- await new Promise(resolve => server.close(resolve));
299
- assert.equal(result.code, 0, result.stderr);
300
- assert.deepEqual(JSON.parse(result.stdout), [{ name: 'launch-system', client: '001', authentication: 'PrincipalPropagation', probe: 'auth-required' }]);
301
- assert.deepEqual(requests, [
302
- { host: `127.0.0.1:${port}`, path: '/api/listDestinations' },
303
- { host: 'launch-system.dest', path: 'http://launch-system.dest/sap/bc/adt/discovery' }
304
- ]);
305
- assert.equal(result.stdout.includes('do-not-print'), false);
306
- });
307
- test('runtime starts destinations even when their ADT probes fail', async () => {
308
- const server = createServer((request, response) => {
309
- if (request.url.includes('/api/listDestinations')) {
310
- response.setHeader('content-type', 'application/json');
311
- response.end(JSON.stringify([{ Name: 'launch-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap' }]));
312
- return;
313
- }
314
- response.writeHead(503);
315
- response.end();
316
- });
317
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
318
- const proxy = `http://127.0.0.1:${server.address().port}`;
319
- try {
320
- const result = await runLauncher([], {
321
- ...process.env,
322
- SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
323
- H2O_URL: 'http://bas.example',
324
- SAP_AI_DEV_TOOLKIT_DESTINATION: 'launch-system',
325
- HTTP_PROXY: proxy,
326
- http_proxy: proxy,
327
- NO_PROXY: '',
328
- no_proxy: ''
329
- });
330
- assert.equal(result.code, 0, result.stderr);
331
- assert.equal(result.stdout, '');
332
- assert.match(result.stderr, /launch-system: probe=unavailable \(HTTP 503\)/);
333
- assert.match(result.stderr, /starting MCP proxy for launch-system \(client=001\)/);
334
- assert.match(result.stderr, /\[launch-system\] starting VSP child/);
335
- } finally {
336
- await new Promise(resolve => server.close(resolve));
337
- }
338
- });
339
- test('setup subprocess writes one isolated MCP entry per selected destination', async () => {
340
- const directory = await mkdtemp(join(tmpdir(), 'bas-launcher-setup-'));
341
- const bin = join(directory, 'bin');
342
- await mkdir(bin);
343
- const cfCli = join(bin, 'cf');
344
- await writeFile(cfCli, '#!/bin/sh\nexit 1\n');
345
- await chmod(cfCli, 0o755);
346
- const config = join(directory, 'mcp.json');
347
- await writeFile(config, JSON.stringify({ inputs: [], servers: {
348
- unrelated: { type: 'stdio', command: 'other' },
349
- 'alpha-system': {
350
- type: 'stdio',
351
- command: 'bas-vsp-mcp',
352
- env: { H2O_URL: 'http://old-h2o.example', BAS_VSP_DESTINATION: 'alpha-system' },
353
- BAS_EXT: 'true'
354
- }
355
- } }));
356
- const server = createServer((request, response) => {
357
- if (request.url === '/api/listDestinations') {
358
- response.setHeader('content-type', 'application/json');
359
- response.end(JSON.stringify([
360
- { Name: 'alpha-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap', 'SAP-Client': '100' },
361
- { Name: 'beta-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': true, WebIDEUsage: 'dev_abap', 'SAP-Client': '200' }
362
- ]));
363
- return;
364
- }
365
- response.writeHead(404);
366
- response.end();
367
- });
368
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
369
- 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 };
370
- try {
371
- const first = await runLauncherTty(env, 'a\r', ['--setup', '--npx']);
372
- assert.equal(first.code, 0, `${first.stdout}\n${first.stderr}`);
373
- let current = JSON.parse(await readFile(config, 'utf8'));
374
- let generated = Object.entries(current.servers).filter(([, entry]) => entry.BAS_EXT === 'true');
375
- assert.deepEqual(generated.map(([, entry]) => entry.env.SAP_AI_DEV_TOOLKIT_DESTINATION), ['alpha-system', 'beta-system'], `${first.stdout}\n${first.stderr}`);
376
- assert.equal(current.servers['alpha-system'].env.BAS_VSP_DESTINATION, undefined);
377
- assert.equal(generated.every(([, entry]) => entry.env.H2O_URL === env.H2O_URL), true);
378
- const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
379
- assert.deepEqual(generated.map(([, entry]) => [entry.command, entry.args]), [
380
- ['npx', ['--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${packageJson.version}`, 'sap-ai-dev']],
381
- ['npx', ['--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${packageJson.version}`, 'sap-ai-dev']]
382
- ]);
383
- const second = await runLauncherTty(env, '\r', ['--setup', '--npx']);
384
- assert.equal(second.code, 0, `${second.stdout}\\n${second.stderr}`);
385
- current = JSON.parse(await readFile(config, 'utf8'));
386
- generated = Object.keys(current.servers).filter(name => name !== 'unrelated');
387
- assert.deepEqual(generated, []);
388
- assert.deepEqual(current.servers.unrelated, { type: 'stdio', command: 'other' });
389
- } finally {
390
- await new Promise(resolve => server.close(resolve));
391
- await rm(directory, { recursive: true, force: true });
392
- }
393
- });
394
-
395
- test('non-BAS list mode reports the required discovery boundary', async () => {
396
- const result = await runLauncher(['--list-destinations', '--json'], { ...process.env, H2O_URL: '' });
397
- assert.equal(result.code, 1);
398
- assert.match(result.stderr, /H2O_URL is required/);
399
- });