sap-ai-dev-toolkit 0.3.4 → 0.3.7
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.
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +22 -0
- package/.github/agents/sap-solution-architect.agent.md +3 -2
- package/.github/skills/hana-cloud-inspection/SKILL.md +35 -0
- package/.github/skills/hana-cloud-native-development/SKILL.md +26 -0
- package/.github/skills/hana-cloud-validation/SKILL.md +26 -0
- package/README.md +40 -17
- package/package.json +9 -4
- package/scripts/postinstall.mjs +3 -3
- package/src/hana-config.mjs +170 -0
- package/src/hana-database.mjs +150 -0
- package/src/hana-inspector.mjs +114 -0
- package/src/hana-tools.mjs +277 -0
- package/src/mcp-config.mjs +10 -1
- package/src/mcp-proxy.mjs +61 -0
- package/src/setup.mjs +3 -2
- package/test/copilot-content.test.mjs +48 -0
- package/test/hana-config.test.mjs +168 -0
- package/test/hana-inspector-stdio.test.mjs +44 -0
- package/test/hana-tools.test.mjs +206 -0
- package/test/live-s4h.test.mjs +202 -0
- package/test/mcp-config-cf.test.mjs +40 -0
- package/test/mcp-proxy.test.mjs +17 -31
- package/test/setup.test.mjs +10 -6
- package/tools.md +58 -50
|
@@ -10,6 +10,7 @@ const skillsRoot = join(root, '.github', 'skills');
|
|
|
10
10
|
const expectedAgents = [
|
|
11
11
|
'abap-developer.agent.md',
|
|
12
12
|
'abap-runtime-debugger.agent.md',
|
|
13
|
+
'hana-cloud-hdi-specialist.agent.md',
|
|
13
14
|
'rap-service-developer.agent.md',
|
|
14
15
|
'sap-solution-architect.agent.md'
|
|
15
16
|
];
|
|
@@ -20,6 +21,9 @@ const expectedSkills = [
|
|
|
20
21
|
'abap-testing-quality',
|
|
21
22
|
'cds-development',
|
|
22
23
|
'clean-core-extensibility',
|
|
24
|
+
'hana-cloud-inspection',
|
|
25
|
+
'hana-cloud-native-development',
|
|
26
|
+
'hana-cloud-validation',
|
|
23
27
|
'rap-development',
|
|
24
28
|
'rap-service-delivery',
|
|
25
29
|
'sap-sdlc-orchestration',
|
|
@@ -136,6 +140,7 @@ test('SAP Solution Architect agent has valid metadata and planning/handoff polic
|
|
|
136
140
|
assert.match(markdown, /ABAP Developer/i);
|
|
137
141
|
assert.match(markdown, /RAP Service Developer/i);
|
|
138
142
|
assert.match(markdown, /ABAP Runtime Debugger/i);
|
|
143
|
+
assert.match(markdown, /HANA Cloud\/HDI Specialist/i);
|
|
139
144
|
assert.match(markdown, /SDLC|lifecycle/i);
|
|
140
145
|
assertPlanFirstPolicy(markdown, path);
|
|
141
146
|
assertLocalFirstPolicy(markdown, path);
|
|
@@ -158,6 +163,27 @@ test('RAP Service Developer agent has valid metadata and plan-first local-first
|
|
|
158
163
|
assertNoUnsafeTransportClaims(markdown, path);
|
|
159
164
|
});
|
|
160
165
|
|
|
166
|
+
test('HANA Cloud/HDI Specialist agent has a read-only target-bound workflow', async () => {
|
|
167
|
+
const path = join(agentsRoot, 'hana-cloud-hdi-specialist.agent.md');
|
|
168
|
+
const markdown = await readMarkdown(path);
|
|
169
|
+
const metadata = parseFrontmatter(markdown, path);
|
|
170
|
+
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
|
|
171
|
+
assert.equal(metadata.name, 'HANA Cloud/HDI Specialist');
|
|
172
|
+
assert.equal(metadata.target, 'vscode');
|
|
173
|
+
assert.equal(metadata['user-invocable'], 'true');
|
|
174
|
+
assert.match(metadata.description, /HANA Cloud/i);
|
|
175
|
+
assertDirectMcpUsage(markdown, path);
|
|
176
|
+
assertMentionsLiveToolDiscovery(markdown, path);
|
|
177
|
+
for (const tool of ['hana_connection_info', 'hana_list_objects', 'hana_describe_object', 'hana_read_rows']) {
|
|
178
|
+
assert.match(markdown, new RegExp(tool));
|
|
179
|
+
}
|
|
180
|
+
assert.match(markdown, /read-only/i);
|
|
181
|
+
assert.match(markdown, /HANA_RO_\*/);
|
|
182
|
+
assert.match(markdown, /cds build --for hana/);
|
|
183
|
+
assert.match(markdown, /user-run deployment|deployment is performed by the user/i);
|
|
184
|
+
assert.match(markdown, /never execute HANA DDL\/DML|Never execute HANA DDL\/DML/i);
|
|
185
|
+
});
|
|
186
|
+
|
|
161
187
|
test('each skill has valid metadata, matching name, and enforceable live-tool validation policy', async () => {
|
|
162
188
|
for (const skill of expectedSkills) {
|
|
163
189
|
const path = join(skillsRoot, skill, 'SKILL.md');
|
|
@@ -199,3 +225,25 @@ test('debugging skill requires evidence before fixes and bounded debugger use',
|
|
|
199
225
|
assert.match(markdown, /Avoid attaching to another user's session|do not.*another user's session/i);
|
|
200
226
|
assert.match(markdown, /before changing code|before proposing a cause/i);
|
|
201
227
|
});
|
|
228
|
+
|
|
229
|
+
test('HANA skills describe the read-only tool surface and safe CAP/HDI generation', async () => {
|
|
230
|
+
const inspection = await readMarkdown(join(skillsRoot, 'hana-cloud-inspection', 'SKILL.md'));
|
|
231
|
+
for (const tool of ['hana_connection_info', 'hana_list_objects', 'hana_describe_object', 'hana_read_rows']) {
|
|
232
|
+
assert.match(inspection, new RegExp(tool));
|
|
233
|
+
}
|
|
234
|
+
assert.match(inspection, /200-row limit/);
|
|
235
|
+
assert.match(inspection, /Do not launch `sap-ai-dev` or `sap-ai-hana` for MCP operations/);
|
|
236
|
+
assert.match(inspection, /schema-bound/);
|
|
237
|
+
assert.match(inspection, /credential-like columns/);
|
|
238
|
+
|
|
239
|
+
const development = await readMarkdown(join(skillsRoot, 'hana-cloud-native-development', 'SKILL.md'));
|
|
240
|
+
assert.match(development, /cds build --for hana/);
|
|
241
|
+
assert.match(development, /CAP-owned database models/);
|
|
242
|
+
assert.match(development, /Never execute `cds deploy` against HANA/);
|
|
243
|
+
assert.match(development, /HANA DDL\/DML/);
|
|
244
|
+
|
|
245
|
+
const validation = await readMarkdown(join(skillsRoot, 'hana-cloud-validation', 'SKILL.md'));
|
|
246
|
+
assert.match(validation, /deployment handoff/);
|
|
247
|
+
assert.match(validation, /HANA tools are metadata and bounded read-only inspection only/);
|
|
248
|
+
assert.match(validation, /Never claim HANA activation\/deployment/);
|
|
249
|
+
});
|
|
@@ -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
|
+
});
|