sap-ai-dev-toolkit 0.3.4 → 0.4.0

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 (35) hide show
  1. package/.github/agents/abap-developer.agent.md +5 -5
  2. package/.github/agents/abap-runtime-debugger.agent.md +10 -10
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +22 -0
  4. package/.github/agents/rap-service-developer.agent.md +10 -10
  5. package/.github/agents/sap-solution-architect.agent.md +7 -6
  6. package/.github/skills/abap-debugging/SKILL.md +2 -2
  7. package/.github/skills/abap-runtime-analysis/SKILL.md +4 -4
  8. package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
  9. package/.github/skills/hana-cloud-inspection/SKILL.md +35 -0
  10. package/.github/skills/hana-cloud-native-development/SKILL.md +26 -0
  11. package/.github/skills/hana-cloud-validation/SKILL.md +26 -0
  12. package/.github/skills/rap-development/SKILL.md +2 -2
  13. package/.github/skills/rap-service-delivery/SKILL.md +3 -3
  14. package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
  15. package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
  16. package/.github/skills/sap-transport-release/SKILL.md +1 -1
  17. package/README.md +46 -22
  18. package/package.json +9 -4
  19. package/scripts/postinstall.mjs +3 -3
  20. package/src/hana-config.mjs +170 -0
  21. package/src/hana-database.mjs +150 -0
  22. package/src/hana-inspector.mjs +114 -0
  23. package/src/hana-tools.mjs +277 -0
  24. package/src/mcp-config.mjs +10 -1
  25. package/src/mcp-proxy.mjs +61 -0
  26. package/src/setup.mjs +3 -2
  27. package/test/copilot-content.test.mjs +48 -0
  28. package/test/hana-config.test.mjs +168 -0
  29. package/test/hana-inspector-stdio.test.mjs +44 -0
  30. package/test/hana-tools.test.mjs +206 -0
  31. package/test/live-s4h.test.mjs +202 -0
  32. package/test/mcp-config-cf.test.mjs +40 -0
  33. package/test/mcp-proxy.test.mjs +17 -31
  34. package/test/setup.test.mjs +10 -6
  35. package/tools.md +58 -50
@@ -0,0 +1,168 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { quoteHanaIdentifier, resolveHanaReadOnlyConfig } from '../src/hana-config.mjs';
4
+ import { hanaConnectionOptions, HanaDatabase } from '../src/hana-database.mjs';
5
+
6
+ const explicitEnv = {
7
+ HANA_RO_HOST: 'hana.example.test',
8
+ HANA_RO_PORT: '443',
9
+ HANA_RO_USER: 'INSPECTOR',
10
+ HANA_RO_PASSWORD: 'do-not-print-this',
11
+ HANA_RO_SCHEMA: 'HDI_CONTAINER_SCHEMA'
12
+ };
13
+
14
+ function vcapBinding(name, user = 'INSPECTOR') {
15
+ return {
16
+ name,
17
+ binding_name: name,
18
+ credentials: {
19
+ host: 'hana.example.test',
20
+ port: 443,
21
+ user,
22
+ password: 'vcap-secret-not-for-output',
23
+ schema: 'HDI_CONTAINER_SCHEMA',
24
+ hdi_user: 'HDI_DEPLOYER',
25
+ hdi_password: 'never-use-this'
26
+ }
27
+ };
28
+ }
29
+
30
+ test('resolves explicit read-only configuration and never disables TLS verification', () => {
31
+ const config = resolveHanaReadOnlyConfig(explicitEnv);
32
+ assert.equal(config.source, 'environment');
33
+ assert.equal(config.user, 'INSPECTOR');
34
+ assert.equal(config.password, 'do-not-print-this');
35
+ assert.equal(config.schema, 'HDI_CONTAINER_SCHEMA');
36
+ assert.equal(Object.isFrozen(config), true);
37
+ const options = hanaConnectionOptions(config);
38
+ assert.equal(options.encrypt, true);
39
+ assert.equal(options.sslValidateCertificate, true);
40
+ assert.equal(options.currentSchema, 'HDI_CONTAINER_SCHEMA');
41
+ assert.equal(options.pwd, 'do-not-print-this');
42
+ });
43
+
44
+ test('rejects incomplete explicit credentials instead of falling back to VCAP', () => {
45
+ assert.throws(() => resolveHanaReadOnlyConfig({
46
+ ...explicitEnv,
47
+ HANA_RO_PASSWORD: '',
48
+ VCAP_SERVICES: JSON.stringify({ hana: [vcapBinding('fallback')] })
49
+ }), /Explicit HANA read-only configuration is incomplete/);
50
+ });
51
+
52
+ test('resolves one selected HANA VCAP binding without using HDI deployment credentials', () => {
53
+ const config = resolveHanaReadOnlyConfig({
54
+ VCAP_SERVICES: JSON.stringify({ hana: [vcapBinding('inspection-binding')] }),
55
+ HANA_RO_BINDING: 'inspection-binding'
56
+ });
57
+ assert.equal(config.source, 'vcap-services');
58
+ assert.equal(config.bindingName, 'inspection-binding');
59
+ assert.equal(config.serviceName, 'hana');
60
+ assert.equal(config.user, 'INSPECTOR');
61
+ assert.equal(config.schema, 'HDI_CONTAINER_SCHEMA');
62
+ assert.equal(config.password, 'vcap-secret-not-for-output');
63
+ });
64
+
65
+ test('fails closed when VCAP contains multiple HANA bindings', () => {
66
+ const env = {
67
+ VCAP_SERVICES: JSON.stringify({ hana: [vcapBinding('first'), vcapBinding('second')] })
68
+ };
69
+ assert.throws(() => resolveHanaReadOnlyConfig(env), /Multiple HANA VCAP bindings/);
70
+ assert.equal(resolveHanaReadOnlyConfig({ ...env, HANA_RO_BINDING: 'second' }).bindingName, 'second');
71
+ });
72
+
73
+ test('rejects an HDI-deployer-only binding and does not disclose its credentials', () => {
74
+ const deployerOnly = {
75
+ name: 'deployer-only',
76
+ credentials: {
77
+ host: 'hana.example.test',
78
+ port: 443,
79
+ schema: 'HDI_CONTAINER_SCHEMA',
80
+ hdi_user: 'HDI_DEPLOYER',
81
+ hdi_password: 'never-use-this'
82
+ }
83
+ };
84
+ assert.throws(() => resolveHanaReadOnlyConfig({
85
+ VCAP_SERVICES: JSON.stringify({ hana: [deployerOnly] })
86
+ }), error => {
87
+ assert.match(error.message, /separate read-only binding/);
88
+ assert.doesNotMatch(error.message, /never-use-this/);
89
+ return true;
90
+ });
91
+ });
92
+
93
+ test('rejects a VCAP binding that aliases its read-only and deployment usernames', () => {
94
+ const binding = vcapBinding('aliased-binding', 'HDI_DEPLOYER');
95
+ assert.throws(() => resolveHanaReadOnlyConfig({
96
+ VCAP_SERVICES: JSON.stringify({ hana: [binding] })
97
+ }), /aliases its read-only and HDI deployment users/);
98
+ });
99
+
100
+ test('rejects bad host, port, missing schema, and missing VCAP configuration', () => {
101
+ assert.throws(() => resolveHanaReadOnlyConfig({ ...explicitEnv, HANA_RO_HOST: 'https://hana.example.test' }), /hostname/);
102
+ assert.throws(() => resolveHanaReadOnlyConfig({ ...explicitEnv, HANA_RO_PORT: '70000' }), /between 1 and 65535/);
103
+ assert.throws(() => resolveHanaReadOnlyConfig({ ...explicitEnv, HANA_RO_SCHEMA: '' }), /incomplete/);
104
+ assert.throws(() => resolveHanaReadOnlyConfig({}), /Set HANA_RO_\*/);
105
+ });
106
+
107
+ test('quotes identifiers by doubling embedded quotes and rejects control characters', () => {
108
+ assert.equal(quoteHanaIdentifier('A"B'), '"A""B"');
109
+ assert.throws(() => quoteHanaIdentifier('bad\nname'), /identifier is invalid/);
110
+ });
111
+
112
+ test('serializes database calls, passes bind values, and redacts native driver errors', async () => {
113
+ const calls = [];
114
+ let disconnects = 0;
115
+ const connection = {
116
+ connect(options, callback) {
117
+ calls.push({ type: 'connect', options });
118
+ callback(null);
119
+ },
120
+ exec(sql, params, options, callback) {
121
+ calls.push({ type: 'exec', sql, params, options });
122
+ if (sql === 'FAIL') callback(Object.assign(new Error('password=do-not-print-this'), { code: 'HY000' }));
123
+ else callback(null, [{ OK: true }]);
124
+ },
125
+ disconnect(callback) {
126
+ disconnects += 1;
127
+ callback(null);
128
+ }
129
+ };
130
+ const db = new HanaDatabase(connection, resolveHanaReadOnlyConfig(explicitEnv));
131
+ await db.connect();
132
+ assert.deepEqual(await db.query('SELECT ? FROM DUMMY', ['value']), [{ OK: true }]);
133
+ await assert.rejects(db.query('FAIL'), error => {
134
+ assert.equal(error.name, 'HanaDatabaseError');
135
+ assert.match(error.message, /HANA error HY000/);
136
+ assert.doesNotMatch(error.message, /do-not-print-this/);
137
+ return true;
138
+ });
139
+ assert.equal(calls[0].options.sslValidateCertificate, true);
140
+ assert.equal(calls[1].params[0], 'value');
141
+ assert.deepEqual(calls[1].options, {});
142
+ await db.close();
143
+ await db.close();
144
+ assert.equal(disconnects, 1);
145
+ });
146
+
147
+ test('times out a hung read, requests cancellation, and retires its connection', async () => {
148
+ let cancels = 0;
149
+ let disconnects = 0;
150
+ const connection = {
151
+ connect(_options, callback) { callback(null); },
152
+ exec() {},
153
+ cancel() { cancels += 1; },
154
+ disconnect(callback) { disconnects += 1; callback(null); }
155
+ };
156
+ const db = new HanaDatabase(connection, resolveHanaReadOnlyConfig(explicitEnv), { queryTimeoutMs: 5 });
157
+ await db.connect();
158
+ await assert.rejects(db.query('SELECT HANG FROM DUMMY'), error => {
159
+ assert.equal(error.name, 'HanaQueryTimeoutError');
160
+ assert.match(error.message, /time limit/);
161
+ assert.doesNotMatch(error.message, /do-not-print-this/);
162
+ return true;
163
+ });
164
+ assert.equal(cancels, 1);
165
+ assert.equal(disconnects, 1);
166
+ assert.equal(db.unusable, true);
167
+ await assert.rejects(db.query('SELECT 1 FROM DUMMY'), /not available/);
168
+ });
@@ -0,0 +1,44 @@
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
+ });
@@ -0,0 +1,206 @@
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
+ });
@@ -0,0 +1,202 @@
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
+
10
+ const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
11
+ const LIVE = /^(1|true|yes)$/iu.test(process.env.SAP_AI_DEV_LIVE_S4H || '');
12
+ const DESTINATION = process.env.SAP_AI_DEV_LIVE_DESTINATION || 'S4H';
13
+ const SLUG = process.env.SAP_AI_DEV_LIVE_DESTINATION_SLUG || DESTINATION.toLowerCase().replace(/[^a-z0-9_-]+/gu, '-');
14
+ const REQUEST_TIMEOUT_MS = Number(process.env.SAP_AI_DEV_LIVE_TIMEOUT_MS || 45_000);
15
+
16
+ async function readConfiguredServer() {
17
+ if (/^(0|false|no)$/iu.test(process.env.SAP_AI_DEV_LIVE_USE_MCP_CONFIG || '')) return null;
18
+ const candidates = [
19
+ process.env.SAP_AI_DEV_MCP_CONFIG,
20
+ process.env.BAS_VSP_MCP_CONFIG,
21
+ join(homedir(), '.vscode', 'data', 'User', 'mcp.json'),
22
+ join(homedir(), '.vscode-server', 'data', 'User', 'mcp.json'),
23
+ join(homedir(), '.code-server', 'data', 'User', 'mcp.json')
24
+ ].filter(Boolean);
25
+ for (const path of candidates) {
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(await readFile(path, 'utf8'));
29
+ } catch {
30
+ continue;
31
+ }
32
+ const server = parsed?.servers?.[DESTINATION];
33
+ if (server?.command) return { path, server };
34
+ }
35
+ return null;
36
+ }
37
+
38
+ class StdioMcpClient {
39
+ constructor(command, args, env) {
40
+ this.child = spawn(command, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
41
+ this.nextId = 1;
42
+ this.pending = new Map();
43
+ this.stdout = '';
44
+ this.stderr = '';
45
+ this.notifications = [];
46
+ this.child.stdout.setEncoding('utf8');
47
+ this.child.stderr.setEncoding('utf8');
48
+ this.child.stdout.on('data', chunk => this.#onStdout(chunk));
49
+ this.child.stderr.on('data', chunk => { this.stderr += chunk; });
50
+ this.child.on('exit', (code, signal) => {
51
+ const error = new Error(`MCP process exited (${code ?? signal})\n${this.stderr.slice(-4000)}`);
52
+ for (const pending of this.pending.values()) pending.reject(error);
53
+ this.pending.clear();
54
+ });
55
+ }
56
+
57
+ #onStdout(chunk) {
58
+ this.stdout += chunk;
59
+ let newline;
60
+ while ((newline = this.stdout.indexOf('\n')) >= 0) {
61
+ const line = this.stdout.slice(0, newline).trim();
62
+ this.stdout = this.stdout.slice(newline + 1);
63
+ if (!line) continue;
64
+ let message;
65
+ try {
66
+ message = JSON.parse(line);
67
+ } catch (error) {
68
+ for (const pending of this.pending.values()) pending.reject(new Error(`Invalid JSON-RPC line: ${line}`));
69
+ this.pending.clear();
70
+ return;
71
+ }
72
+ if (message.id === undefined || message.id === null) {
73
+ this.notifications.push(message);
74
+ continue;
75
+ }
76
+ const pending = this.pending.get(message.id);
77
+ if (!pending) continue;
78
+ this.pending.delete(message.id);
79
+ clearTimeout(pending.timeout);
80
+ if (message.error) pending.reject(Object.assign(new Error(message.error.message), { response: message }));
81
+ else pending.resolve(message.result);
82
+ }
83
+ }
84
+
85
+ request(method, params) {
86
+ const id = this.nextId++;
87
+ const payload = { jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) };
88
+ return new Promise((resolve, reject) => {
89
+ const timeout = setTimeout(() => {
90
+ this.pending.delete(id);
91
+ reject(new Error(`Timed out waiting for ${method}\n${this.stderr.slice(-4000)}`));
92
+ }, REQUEST_TIMEOUT_MS);
93
+ this.pending.set(id, { resolve, reject, timeout });
94
+ this.child.stdin.write(`${JSON.stringify(payload)}\n`);
95
+ });
96
+ }
97
+
98
+ notify(method, params) {
99
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) })}\n`);
100
+ }
101
+
102
+ async close() {
103
+ this.child.stdin.end();
104
+ this.child.kill('SIGTERM');
105
+ await Promise.race([
106
+ once(this.child, 'exit').catch(() => {}),
107
+ new Promise(resolve => setTimeout(resolve, 1500))
108
+ ]);
109
+ if (!this.child.killed) this.child.kill('SIGKILL');
110
+ }
111
+ }
112
+
113
+ function textPayload(result) {
114
+ return (result?.content || []).filter(item => item.type === 'text').map(item => item.text).join('\n');
115
+ }
116
+
117
+ async function withLiveClient(t) {
118
+ const configured = await readConfiguredServer();
119
+ const server = configured?.server;
120
+ const command = process.env.SAP_AI_DEV_LIVE_COMMAND || server?.command || process.execPath;
121
+ const args = process.env.SAP_AI_DEV_LIVE_COMMAND
122
+ ? (process.env.SAP_AI_DEV_LIVE_ARGS ? JSON.parse(process.env.SAP_AI_DEV_LIVE_ARGS) : [])
123
+ : (server?.command ? (server.args || []) : [launcher]);
124
+ const client = new StdioMcpClient(command, args, {
125
+ ...process.env,
126
+ ...(server?.env || {}),
127
+ SAP_AI_DEV_TOOLKIT_DESTINATION: server?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION || DESTINATION,
128
+ SAP_AI_DEV_TOOLKIT_MODE: process.env.SAP_AI_DEV_TOOLKIT_MODE || server?.env?.SAP_AI_DEV_TOOLKIT_MODE || 'expert',
129
+ SAP_ALLOW_TRANSPORTABLE_EDITS: process.env.SAP_ALLOW_TRANSPORTABLE_EDITS || server?.env?.SAP_ALLOW_TRANSPORTABLE_EDITS || 'true'
130
+ });
131
+ t.after(() => client.close());
132
+ const initialized = await client.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'sap-ai-dev-live-test', version: '1.0.0' } });
133
+ client.notify('notifications/initialized');
134
+ assert.equal(initialized.serverInfo.name, DESTINATION);
135
+ return client;
136
+ }
137
+
138
+ 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 => {
139
+ const client = await withLiveClient(t);
140
+ const listed = await client.request('tools/list', {});
141
+ const tools = listed.tools || [];
142
+ const names = new Set(tools.map(tool => tool.name));
143
+ const prefix = `${SLUG}__`;
144
+
145
+ assert.ok(tools.length > 25, `expected a useful ABAP lifecycle surface, got ${tools.length}`);
146
+ assert.ok(tools.length < 60, `curated destination tool count must stay below 60, got ${tools.length}`);
147
+ assert.ok(tools.every(tool => tool.name.startsWith(prefix)), 'every tool is destination-prefixed for the selected S4H server');
148
+
149
+ for (const required of [
150
+ 'LintABAP', 'GetSystemInfo', 'GetConnectionInfo', 'GetFeatures',
151
+ 'SearchObject', 'GrepObjects', 'GrepPackages', 'GetSource', 'WriteSource', 'EditSource', 'CompareSource',
152
+ 'SyntaxCheck', 'PrettyPrint', 'Activate', 'RunUnitTests', 'RunATCCheck', 'GetInactiveObjects',
153
+ 'GetClass', 'GetProgram', 'GetPackage', 'GetTable', 'GetTableContents', 'RunQuery',
154
+ 'GetTransport', 'GetTransportInfo', 'ListTransports', 'CreateTransport',
155
+ 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration'
156
+ ]) {
157
+ assert.ok(names.has(`${prefix}${required}`), `expected curated lifecycle tool ${required}`);
158
+ }
159
+
160
+ for (const hidden of [
161
+ 'SAP', 'ReleaseTransport', 'DeleteTransport', 'DeleteObject', 'ExecuteABAP', 'CallRFC',
162
+ 'ListSQLTraces', 'AnalyzeABAPCode', 'CreateObject', 'CreateClassWithTests', 'WriteClass', 'UpdateSource'
163
+ ]) {
164
+ assert.equal(names.has(`${prefix}${hidden}`), false, `broad/destructive tool ${hidden} must remain hidden`);
165
+ }
166
+ });
167
+
168
+ 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 => {
169
+ const client = await withLiveClient(t);
170
+ const prefix = `${SLUG}__`;
171
+ await client.request('tools/list', {});
172
+
173
+ const lint = await client.request('tools/call', {
174
+ name: `${prefix}LintABAP`,
175
+ arguments: { files: [{ filename: 'zlive_probe.prog.abap', source: "REPORT zlive_probe.\nWRITE / 'ok'.\n" }] }
176
+ });
177
+ const lintJson = JSON.parse(textPayload(lint));
178
+ assert.equal(lintJson.filesChecked, 1);
179
+
180
+ for (const [tool, args] of [
181
+ ['GetSystemInfo', {}],
182
+ ['GetConnectionInfo', {}],
183
+ ['GetFeatures', {}],
184
+ ['GetInstalledComponents', {}]
185
+ ]) {
186
+ const result = await client.request('tools/call', { name: `${prefix}${tool}`, arguments: args });
187
+ assert.notEqual(result.isError, true, `${tool} returned an MCP tool error: ${textPayload(result)}`);
188
+ }
189
+
190
+ const query = await client.request('tools/call', {
191
+ name: `${prefix}RunQuery`,
192
+ arguments: { sql_query: 'SELECT * FROM T000', max_rows: 1 }
193
+ });
194
+ assert.notEqual(query.isError, true, `RunQuery returned an MCP tool error: ${textPayload(query)}`);
195
+ assert.match(textPayload(query), /T000|MANDT|client|rows|\[/iu);
196
+
197
+ const appLog = await client.request('tools/call', {
198
+ name: `${prefix}GetApplicationLog`,
199
+ arguments: { max_results: 1 }
200
+ });
201
+ assert.notEqual(appLog.isError, true, `GetApplicationLog returned an MCP tool error: ${textPayload(appLog)}`);
202
+ });
@@ -100,6 +100,17 @@ test('builds SAP development companion MCP entries', () => {
100
100
  assert.throws(() => buildSapDevelopmentMcpEntries(['missing-tool']), /Unknown SAP development MCP server id/);
101
101
  });
102
102
 
103
+ test('configures the HANA inspector without persisting credentials', () => {
104
+ const entry = buildSapDevelopmentMcpEntries(['hana-cloud-inspector'])['hana-cloud-inspector'];
105
+ assert.equal(entry.command, 'npx');
106
+ assert.deepEqual(entry.args, ['--yes', '--ignore-scripts', '--package=sap-ai-dev-toolkit', 'sap-ai-hana']);
107
+ assert.equal(entry.BAS_EXT_KIND, 'sap-development-companion');
108
+ assert.equal(entry.displayName, 'HANA Cloud inspector');
109
+ assert.equal(entry.env, undefined);
110
+ assert.doesNotMatch(JSON.stringify(entry), /"(?:HANA_RO_PASSWORD|VCAP_SERVICES)"\s*:/);
111
+ assert.doesNotMatch(JSON.stringify(entry), /"(?:password|pwd)"\s*:/i);
112
+ });
113
+
103
114
  test('installs and removes SAP development companion entries', async t => {
104
115
  const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-tools-config-'));
105
116
  const path = join(directory, 'mcp.json');
@@ -120,6 +131,35 @@ test('installs and removes SAP development companion entries', async t => {
120
131
  assert.deepEqual(Object.keys(cleared.servers), ['userServer']);
121
132
  });
122
133
 
134
+ test('installs and removes the HANA inspector without changing unrelated MCP servers', async t => {
135
+ const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-hana-config-'));
136
+ const path = join(directory, 'mcp.json');
137
+ const initial = {
138
+ custom: true,
139
+ servers: { userServer: { type: 'stdio', command: 'custom-server' } }
140
+ };
141
+ await writeFile(path, JSON.stringify(initial));
142
+ t.after(() => rm(directory, { recursive: true, force: true }));
143
+
144
+ const installed = await installMcpConfig([], {
145
+ env: { H2O_URL: 'http://h2o.example' },
146
+ path,
147
+ sapDevelopmentServers: ['hana-cloud-inspector']
148
+ });
149
+ assert.deepEqual(Object.keys(installed.servers), ['hana-cloud-inspector']);
150
+ const configured = await readMcpConfig(path);
151
+ assert.deepEqual(Object.keys(configured.servers).sort(), ['hana-cloud-inspector', 'userServer']);
152
+ assert.equal(configured.custom, true);
153
+ assert.equal(configured.servers['hana-cloud-inspector'].env, undefined);
154
+ assert.doesNotMatch(JSON.stringify(configured), /"(?:HANA_RO_PASSWORD|VCAP_SERVICES)"\s*:/);
155
+ assert.doesNotMatch(JSON.stringify(configured), /"(?:password|pwd)"\s*:/i);
156
+
157
+ await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
158
+ const removed = await readMcpConfig(path);
159
+ assert.deepEqual(removed.servers, initial.servers);
160
+ assert.equal(removed.custom, true);
161
+ });
162
+
123
163
  test('installs and removes CF entries without deleting unrelated MCP servers', async t => {
124
164
  const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-cf-config-'));
125
165
  const path = join(directory, 'mcp.json');