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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const QUERY_TIMEOUT_MS = 15_000;
|
|
5
|
+
|
|
6
|
+
function hanaFailure(operation, error) {
|
|
7
|
+
const code = typeof error?.code === 'string' || typeof error?.code === 'number'
|
|
8
|
+
? String(error.code).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 24)
|
|
9
|
+
: '';
|
|
10
|
+
const failure = new Error(`${operation} failed${code ? ` (HANA error ${code})` : ''}; verify the selected read-only binding, schema, and privileges.`);
|
|
11
|
+
failure.name = 'HanaDatabaseError';
|
|
12
|
+
return failure;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function callbackPromise(invoke, operation) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
let settled = false;
|
|
18
|
+
const done = (error, result) => {
|
|
19
|
+
if (settled) return;
|
|
20
|
+
settled = true;
|
|
21
|
+
if (error) reject(hanaFailure(operation, error));
|
|
22
|
+
else resolve(result);
|
|
23
|
+
};
|
|
24
|
+
try {
|
|
25
|
+
invoke(done);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
done(error);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function executeWithTimeout(connection, sql, values, timeoutMs, onTimeout) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let settled = false;
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
if (settled) return;
|
|
37
|
+
settled = true;
|
|
38
|
+
onTimeout();
|
|
39
|
+
const error = new Error(`HANA read exceeded the ${timeoutMs / 1000} second time limit; the connection was retired. Narrow the request and retry.`);
|
|
40
|
+
error.name = 'HanaQueryTimeoutError';
|
|
41
|
+
reject(error);
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
const done = (error, result) => {
|
|
44
|
+
if (settled) return;
|
|
45
|
+
settled = true;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
if (error) reject(hanaFailure('HANA read', error));
|
|
48
|
+
else resolve(result);
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
connection.exec(sql, values, {}, done);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
done(error);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function hanaConnectionOptions(config) {
|
|
59
|
+
return {
|
|
60
|
+
host: config.host,
|
|
61
|
+
port: config.port,
|
|
62
|
+
uid: config.user,
|
|
63
|
+
pwd: config.password,
|
|
64
|
+
currentSchema: config.schema,
|
|
65
|
+
encrypt: true,
|
|
66
|
+
sslValidateCertificate: true,
|
|
67
|
+
connectTimeout: 10_000,
|
|
68
|
+
...(config.trustStore ? { sslTrustStore: config.trustStore } : {})
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class HanaDatabase {
|
|
73
|
+
constructor(connection, config, { queryTimeoutMs = QUERY_TIMEOUT_MS } = {}) {
|
|
74
|
+
this.connection = connection;
|
|
75
|
+
this.config = config;
|
|
76
|
+
this.queryTimeoutMs = queryTimeoutMs;
|
|
77
|
+
this.connected = false;
|
|
78
|
+
this.closed = false;
|
|
79
|
+
this.unusable = false;
|
|
80
|
+
this.queue = Promise.resolve();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async connect() {
|
|
84
|
+
if (this.closed) throw new Error('HANA connection is closed.');
|
|
85
|
+
if (this.connected) return this;
|
|
86
|
+
await callbackPromise(
|
|
87
|
+
callback => this.connection.connect(hanaConnectionOptions(this.config), callback),
|
|
88
|
+
'HANA connection'
|
|
89
|
+
);
|
|
90
|
+
this.connected = true;
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async query(sql, values = []) {
|
|
95
|
+
if (!this.connected || this.closed || this.unusable) throw new Error('HANA connection is not available.');
|
|
96
|
+
if (typeof sql !== 'string' || !sql.trim() || !Array.isArray(values)) {
|
|
97
|
+
throw new Error('Invalid internal HANA query request.');
|
|
98
|
+
}
|
|
99
|
+
const result = this.queue.then(() => executeWithTimeout(
|
|
100
|
+
this.connection,
|
|
101
|
+
sql,
|
|
102
|
+
values,
|
|
103
|
+
this.queryTimeoutMs,
|
|
104
|
+
() => {
|
|
105
|
+
this.unusable = true;
|
|
106
|
+
try { this.connection.cancel?.(); } catch {}
|
|
107
|
+
}
|
|
108
|
+
));
|
|
109
|
+
this.queue = result.then(() => undefined, () => undefined);
|
|
110
|
+
try {
|
|
111
|
+
return await result;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (this.unusable) await this.close();
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async close() {
|
|
119
|
+
if (this.closed) return;
|
|
120
|
+
this.closed = true;
|
|
121
|
+
await this.queue.catch(() => {});
|
|
122
|
+
if (!this.connected) return;
|
|
123
|
+
this.connected = false;
|
|
124
|
+
await callbackPromise(callback => this.connection.disconnect(callback), 'HANA disconnect').catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function connectReadOnlyHana(config, { driver } = {}) {
|
|
129
|
+
let selectedDriver = driver;
|
|
130
|
+
if (!selectedDriver) {
|
|
131
|
+
try {
|
|
132
|
+
selectedDriver = require('@sap/hana-client');
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error('The SAP HANA Node.js driver could not be loaded for this platform.');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (typeof selectedDriver?.createConnection !== 'function') {
|
|
138
|
+
throw new Error('The configured SAP HANA Node.js driver is unavailable.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const connection = selectedDriver.createConnection();
|
|
142
|
+
const database = new HanaDatabase(connection, config);
|
|
143
|
+
try {
|
|
144
|
+
await database.connect();
|
|
145
|
+
return database;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
await database.close();
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { resolveHanaReadOnlyConfig } from './hana-config.mjs';
|
|
7
|
+
import { connectReadOnlyHana } from './hana-database.mjs';
|
|
8
|
+
import { registerHanaTools } from './hana-tools.mjs';
|
|
9
|
+
|
|
10
|
+
const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
11
|
+
|
|
12
|
+
function publicConnectionSummary(config) {
|
|
13
|
+
return Object.freeze({
|
|
14
|
+
source: config.source,
|
|
15
|
+
serviceName: config.serviceName,
|
|
16
|
+
bindingName: config.bindingName,
|
|
17
|
+
host: config.host,
|
|
18
|
+
port: config.port,
|
|
19
|
+
schema: config.schema
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Create an injectable server runtime for tests and the stdio executable. */
|
|
24
|
+
export function createHanaInspector({ env = process.env, driver, databaseFactory = connectReadOnlyHana } = {}) {
|
|
25
|
+
const config = resolveHanaReadOnlyConfig(env);
|
|
26
|
+
const summary = publicConnectionSummary(config);
|
|
27
|
+
let databasePromise;
|
|
28
|
+
let database;
|
|
29
|
+
|
|
30
|
+
const getDatabase = async () => {
|
|
31
|
+
if (!databasePromise) {
|
|
32
|
+
databasePromise = databaseFactory(config, { driver })
|
|
33
|
+
.then(connected => {
|
|
34
|
+
database = connected;
|
|
35
|
+
return connected;
|
|
36
|
+
})
|
|
37
|
+
.catch(error => {
|
|
38
|
+
databasePromise = undefined;
|
|
39
|
+
throw error;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return databasePromise;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const server = new McpServer({
|
|
46
|
+
name: 'sap-ai-hana-inspector',
|
|
47
|
+
version: packageMetadata.version
|
|
48
|
+
}, { capabilities: { tools: {} } });
|
|
49
|
+
registerHanaTools(server, getDatabase, { connectionSummary: summary });
|
|
50
|
+
|
|
51
|
+
let closing;
|
|
52
|
+
const close = () => {
|
|
53
|
+
if (!closing) {
|
|
54
|
+
closing = (async () => {
|
|
55
|
+
await server.close().catch(() => {});
|
|
56
|
+
if (!database && databasePromise) database = await databasePromise.catch(() => undefined);
|
|
57
|
+
if (database) await database.close().catch(() => {});
|
|
58
|
+
})();
|
|
59
|
+
}
|
|
60
|
+
return closing;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return { server, close, connectionSummary: summary };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function runHanaInspector({ env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, driver } = {}) {
|
|
67
|
+
if (process.argv.slice(2).some(argument => argument === '--help' || argument === '-h')) {
|
|
68
|
+
stdout.write([
|
|
69
|
+
'Usage: sap-ai-hana',
|
|
70
|
+
'',
|
|
71
|
+
'Starts a read-only stdio MCP server for the HANA service selected by HANA_RO_* or VCAP_SERVICES.',
|
|
72
|
+
'Required explicit variables: HANA_RO_HOST, HANA_RO_PORT, HANA_RO_USER, HANA_RO_PASSWORD, HANA_RO_SCHEMA.',
|
|
73
|
+
'VCAP selection: HANA_RO_VCAP_SERVICE and HANA_RO_BINDING are optional when exactly one HANA binding exists.',
|
|
74
|
+
'TLS certificate validation is always enabled. Deployment credentials are never used as a fallback.',
|
|
75
|
+
''
|
|
76
|
+
].join('\n'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let runtime;
|
|
80
|
+
try {
|
|
81
|
+
runtime = createHanaInspector({ env, driver });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
stderr.write(`[sap-ai-hana] ${error.message}\n`);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const transport = new StdioServerTransport(stdin, stdout, { maxBufferSize: 1024 * 1024 });
|
|
89
|
+
let closing;
|
|
90
|
+
const shutdown = () => {
|
|
91
|
+
if (!closing) closing = runtime.close().catch(() => {});
|
|
92
|
+
return closing;
|
|
93
|
+
};
|
|
94
|
+
const onStdinEnd = () => { void shutdown(); };
|
|
95
|
+
const onSignal = signal => {
|
|
96
|
+
void shutdown().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
|
|
97
|
+
};
|
|
98
|
+
stdin.once('end', onStdinEnd);
|
|
99
|
+
process.once('SIGINT', onSignal);
|
|
100
|
+
process.once('SIGTERM', onSignal);
|
|
101
|
+
transport.onerror = () => stderr.write('[sap-ai-hana] MCP stdio transport failed.\n');
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await runtime.server.connect(transport);
|
|
105
|
+
} catch {
|
|
106
|
+
stderr.write('[sap-ai-hana] MCP server startup failed.\n');
|
|
107
|
+
await shutdown();
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
113
|
+
await runHanaInspector();
|
|
114
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { quoteHanaIdentifier } from './hana-config.mjs';
|
|
3
|
+
|
|
4
|
+
export const HANA_MAX_ROWS = 200;
|
|
5
|
+
export const HANA_MAX_OUTPUT_BYTES = 96 * 1024;
|
|
6
|
+
const MAX_COLUMNS = 40;
|
|
7
|
+
const MAX_FILTERS = 20;
|
|
8
|
+
const MAX_SELECTED_COLUMN_WIDTH = 8192;
|
|
9
|
+
const UNBOUNDED_COLUMN_TYPES = new Set(['BLOB', 'CLOB', 'NCLOB', 'BINARY', 'VARBINARY', 'ST_GEOMETRY', 'ST_POINT']);
|
|
10
|
+
const SENSITIVE_COLUMN_NAME = /(?:PASS(?:WORD|WD)|SECRET|(?:ACCESS|REFRESH|AUTH)?_?TOKEN|API_?KEY|PRIVATE_?KEY|CREDENTIAL)/i;
|
|
11
|
+
const SAFE_INSPECTION_ERRORS = new Set([
|
|
12
|
+
'HANA result is too large to return safely; request fewer rows or columns.',
|
|
13
|
+
'The requested object was not found in the bound HDI schema.',
|
|
14
|
+
'A requested column is not present on the requested HDI object.',
|
|
15
|
+
'Credential-like columns are not returned by the HANA inspector.',
|
|
16
|
+
'LOB and binary columns are not supported by the bounded row-read tool.',
|
|
17
|
+
'The selected columns exceed the safe row-width limit; select fewer or narrower columns.'
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const READ_ONLY_ANNOTATIONS = Object.freeze({
|
|
21
|
+
readOnlyHint: true,
|
|
22
|
+
destructiveHint: false,
|
|
23
|
+
idempotentHint: true,
|
|
24
|
+
openWorldHint: false
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const OPERATOR_SQL = Object.freeze({
|
|
28
|
+
eq: '=',
|
|
29
|
+
ne: '<>',
|
|
30
|
+
lt: '<',
|
|
31
|
+
lte: '<=',
|
|
32
|
+
gt: '>',
|
|
33
|
+
gte: '>='
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function jsonSafe(value, seen = new WeakSet()) {
|
|
37
|
+
if (typeof value === 'bigint') return value.toString();
|
|
38
|
+
if (value instanceof Date) return value.toISOString();
|
|
39
|
+
if (Buffer.isBuffer(value)) return { type: 'binary', bytes: value.byteLength };
|
|
40
|
+
if (Array.isArray(value)) return value.map(entry => jsonSafe(entry, seen));
|
|
41
|
+
if (value && typeof value === 'object') {
|
|
42
|
+
if (seen.has(value)) return '[circular]';
|
|
43
|
+
seen.add(value);
|
|
44
|
+
const result = Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, jsonSafe(entry, seen)]));
|
|
45
|
+
seen.delete(value);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function jsonText(value) {
|
|
53
|
+
const text = JSON.stringify(jsonSafe(value), null, 2);
|
|
54
|
+
if (Buffer.byteLength(text, 'utf8') > HANA_MAX_OUTPUT_BYTES) {
|
|
55
|
+
throw new Error('HANA result is too large to return safely; request fewer rows or columns.');
|
|
56
|
+
}
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toolError(error) {
|
|
61
|
+
let message = 'HANA inspection failed. Check the selected read-only binding, target schema, and read permissions.';
|
|
62
|
+
if (error?.name === 'HanaDatabaseError' || error?.name === 'HanaQueryTimeoutError') {
|
|
63
|
+
message = error.message;
|
|
64
|
+
} else if (SAFE_INSPECTION_ERRORS.has(error?.message)) {
|
|
65
|
+
message = error.message;
|
|
66
|
+
} else if (typeof error?.message === 'string' && /^(?:Set HANA_RO_|Explicit HANA |VCAP_SERVICES|The selected HANA |No HANA VCAP|Multiple HANA VCAP|The configured HANA |HANA_RO_HOST|HANA_RO_PORT)/.test(error.message)) {
|
|
67
|
+
message = error.message;
|
|
68
|
+
}
|
|
69
|
+
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function resultOf(operation) {
|
|
73
|
+
try {
|
|
74
|
+
return { content: [{ type: 'text', text: jsonText(await operation()) }] };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return toolError(error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function rowsOf(value) {
|
|
81
|
+
return Array.isArray(value) ? value : [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function listObjects(database, schema, objectType, maxResults) {
|
|
85
|
+
const entries = [];
|
|
86
|
+
if (objectType !== 'VIEW') {
|
|
87
|
+
const tables = rowsOf(await database.query(
|
|
88
|
+
`SELECT TABLE_NAME AS OBJECT_NAME FROM SYS.TABLES WHERE SCHEMA_NAME = ? ORDER BY TABLE_NAME LIMIT ${maxResults}`,
|
|
89
|
+
[schema]
|
|
90
|
+
));
|
|
91
|
+
entries.push(...tables.map(row => ({ objectName: row.OBJECT_NAME, objectType: 'TABLE' })));
|
|
92
|
+
}
|
|
93
|
+
if (objectType !== 'TABLE') {
|
|
94
|
+
const views = rowsOf(await database.query(
|
|
95
|
+
`SELECT VIEW_NAME AS OBJECT_NAME FROM SYS.VIEWS WHERE SCHEMA_NAME = ? ORDER BY VIEW_NAME LIMIT ${maxResults}`,
|
|
96
|
+
[schema]
|
|
97
|
+
));
|
|
98
|
+
entries.push(...views.map(row => ({ objectName: row.OBJECT_NAME, objectType: 'VIEW' })));
|
|
99
|
+
}
|
|
100
|
+
entries.sort((left, right) => String(left.objectName).localeCompare(String(right.objectName)) || left.objectType.localeCompare(right.objectType));
|
|
101
|
+
return {
|
|
102
|
+
schema,
|
|
103
|
+
objectType,
|
|
104
|
+
objects: entries.slice(0, maxResults),
|
|
105
|
+
maxResults,
|
|
106
|
+
truncated: entries.length >= maxResults
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function findObject(database, schema, objectName) {
|
|
111
|
+
const tables = rowsOf(await database.query(
|
|
112
|
+
'SELECT TABLE_NAME AS OBJECT_NAME FROM SYS.TABLES WHERE SCHEMA_NAME = ? AND TABLE_NAME = ?',
|
|
113
|
+
[schema, objectName]
|
|
114
|
+
));
|
|
115
|
+
if (tables.length) return { objectName, objectType: 'TABLE' };
|
|
116
|
+
const views = rowsOf(await database.query(
|
|
117
|
+
'SELECT VIEW_NAME AS OBJECT_NAME FROM SYS.VIEWS WHERE SCHEMA_NAME = ? AND VIEW_NAME = ?',
|
|
118
|
+
[schema, objectName]
|
|
119
|
+
));
|
|
120
|
+
if (views.length) return { objectName, objectType: 'VIEW' };
|
|
121
|
+
throw new Error('The requested object was not found in the bound HDI schema.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function describeObject(database, schema, objectName) {
|
|
125
|
+
const target = await findObject(database, schema, objectName);
|
|
126
|
+
const sql = target.objectType === 'TABLE'
|
|
127
|
+
? 'SELECT COLUMN_NAME, POSITION, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ORDER BY POSITION'
|
|
128
|
+
: 'SELECT COLUMN_NAME, POSITION, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE FROM SYS.VIEW_COLUMNS WHERE SCHEMA_NAME = ? AND VIEW_NAME = ? ORDER BY POSITION';
|
|
129
|
+
const columns = rowsOf(await database.query(sql, [schema, objectName]));
|
|
130
|
+
return { ...target, schema, columns };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertRequestedColumns(columns, metadataColumns) {
|
|
134
|
+
const available = new Set(metadataColumns.map(column => column.COLUMN_NAME));
|
|
135
|
+
for (const name of columns) {
|
|
136
|
+
if (!available.has(name)) throw new Error('A requested column is not present on the requested HDI object.');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function assertBoundedColumns(columns, metadataColumns) {
|
|
141
|
+
let estimatedWidth = 0;
|
|
142
|
+
for (const name of columns) {
|
|
143
|
+
const column = metadataColumns.find(entry => entry.COLUMN_NAME === name);
|
|
144
|
+
const type = String(column.DATA_TYPE_NAME || '').toUpperCase();
|
|
145
|
+
if (UNBOUNDED_COLUMN_TYPES.has(type)) {
|
|
146
|
+
throw new Error('LOB and binary columns are not supported by the bounded row-read tool.');
|
|
147
|
+
}
|
|
148
|
+
const length = Number(column.LENGTH);
|
|
149
|
+
estimatedWidth += Number.isFinite(length) && length > 0 ? length : 32;
|
|
150
|
+
if (estimatedWidth > MAX_SELECTED_COLUMN_WIDTH) {
|
|
151
|
+
throw new Error('The selected columns exceed the safe row-width limit; select fewer or narrower columns.');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function assertNonSensitiveColumns(columns) {
|
|
157
|
+
if (columns.some(name => SENSITIVE_COLUMN_NAME.test(name))) {
|
|
158
|
+
throw new Error('Credential-like columns are not returned by the HANA inspector.');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function readRows(database, schema, args) {
|
|
163
|
+
const target = await findObject(database, schema, args.objectName);
|
|
164
|
+
const metadata = await describeObject(database, schema, args.objectName);
|
|
165
|
+
assertRequestedColumns(args.columns, metadata.columns);
|
|
166
|
+
assertNonSensitiveColumns([
|
|
167
|
+
...args.columns,
|
|
168
|
+
...args.filters.map(filter => filter.column),
|
|
169
|
+
...(args.orderBy ? [args.orderBy.column] : [])
|
|
170
|
+
]);
|
|
171
|
+
assertBoundedColumns(args.columns, metadata.columns);
|
|
172
|
+
|
|
173
|
+
for (const filter of args.filters) assertRequestedColumns([filter.column], metadata.columns);
|
|
174
|
+
if (args.orderBy) assertRequestedColumns([args.orderBy.column], metadata.columns);
|
|
175
|
+
|
|
176
|
+
const selected = args.columns.map(quoteHanaIdentifier).join(', ');
|
|
177
|
+
const conditions = args.filters.map(filter => `${quoteHanaIdentifier(filter.column)} ${OPERATOR_SQL[filter.operator]} ?`);
|
|
178
|
+
const order = args.orderBy
|
|
179
|
+
? ` ORDER BY ${quoteHanaIdentifier(args.orderBy.column)} ${args.orderBy.direction}`
|
|
180
|
+
: '';
|
|
181
|
+
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
182
|
+
const sql = `SELECT ${selected} FROM ${quoteHanaIdentifier(schema)}.${quoteHanaIdentifier(args.objectName)}${where}${order} LIMIT ${args.limit}`;
|
|
183
|
+
const values = args.filters.map(filter => filter.value);
|
|
184
|
+
const rows = rowsOf(await database.query(sql, values));
|
|
185
|
+
return {
|
|
186
|
+
schema,
|
|
187
|
+
...target,
|
|
188
|
+
columns: args.columns,
|
|
189
|
+
limit: args.limit,
|
|
190
|
+
rowCount: rows.length,
|
|
191
|
+
rows,
|
|
192
|
+
possiblyTruncated: rows.length === args.limit
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Register read-only inspection tools. No handler accepts arbitrary SQL or a caller-selected schema. */
|
|
197
|
+
export function registerHanaTools(server, getDatabase, { connectionSummary } = {}) {
|
|
198
|
+
const schemaName = connectionSummary?.schema;
|
|
199
|
+
if (typeof schemaName !== 'string' || !schemaName.trim()) {
|
|
200
|
+
throw new Error('A configured HDI schema is required to register HANA tools.');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
server.registerTool('hana_connection_info', {
|
|
204
|
+
title: 'HANA connection info',
|
|
205
|
+
description: 'Return the configured HANA Cloud endpoint and verified HDI schema/current user. Uses the dedicated read-only identity; never returns credentials.',
|
|
206
|
+
inputSchema: {},
|
|
207
|
+
annotations: READ_ONLY_ANNOTATIONS
|
|
208
|
+
}, async () => resultOf(async () => {
|
|
209
|
+
const database = await getDatabase();
|
|
210
|
+
const rows = rowsOf(await database.query('SELECT CURRENT_SCHEMA AS CURRENT_SCHEMA, CURRENT_USER AS CURRENT_USER FROM DUMMY'));
|
|
211
|
+
if (!rows.length) throw new Error('HANA did not return its current connection identity.');
|
|
212
|
+
const currentSchema = rows[0].CURRENT_SCHEMA;
|
|
213
|
+
if (currentSchema !== schemaName) throw new Error('The connected HANA schema does not match the configured HDI schema.');
|
|
214
|
+
return {
|
|
215
|
+
source: connectionSummary.source,
|
|
216
|
+
serviceName: connectionSummary.serviceName,
|
|
217
|
+
bindingName: connectionSummary.bindingName,
|
|
218
|
+
host: connectionSummary.host,
|
|
219
|
+
port: connectionSummary.port,
|
|
220
|
+
configuredSchema: schemaName,
|
|
221
|
+
currentSchema,
|
|
222
|
+
databaseUser: rows[0].CURRENT_USER,
|
|
223
|
+
tls: 'enabled; certificate verification required'
|
|
224
|
+
};
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
server.registerTool('hana_list_objects', {
|
|
228
|
+
title: 'List HANA objects',
|
|
229
|
+
description: 'List tables and/or views from the configured HDI schema only. Results are capped at 200 objects.',
|
|
230
|
+
inputSchema: {
|
|
231
|
+
objectType: z.enum(['ALL', 'TABLE', 'VIEW']).default('ALL'),
|
|
232
|
+
maxResults: z.number().int().min(1).max(HANA_MAX_ROWS).default(100)
|
|
233
|
+
},
|
|
234
|
+
annotations: READ_ONLY_ANNOTATIONS
|
|
235
|
+
}, async ({ objectType, maxResults }) => resultOf(async () => {
|
|
236
|
+
return listObjects(await getDatabase(), schemaName, objectType, maxResults);
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
server.registerTool('hana_describe_object', {
|
|
240
|
+
title: 'Describe HANA object',
|
|
241
|
+
description: 'Return column metadata for a table or view in the configured HDI schema only.',
|
|
242
|
+
inputSchema: { objectName: z.string().trim().min(1).max(127) },
|
|
243
|
+
annotations: READ_ONLY_ANNOTATIONS
|
|
244
|
+
}, async ({ objectName }) => resultOf(async () => {
|
|
245
|
+
return describeObject(await getDatabase(), schemaName, objectName);
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
const valueSchema = z.union([z.string().max(2048), z.number().finite(), z.boolean()]);
|
|
249
|
+
const filterSchema = z.object({
|
|
250
|
+
column: z.string().trim().min(1).max(127),
|
|
251
|
+
operator: z.enum(['eq', 'ne', 'lt', 'lte', 'gt', 'gte']),
|
|
252
|
+
value: valueSchema
|
|
253
|
+
}).strict();
|
|
254
|
+
|
|
255
|
+
server.registerTool('hana_read_rows', {
|
|
256
|
+
title: 'Read HANA rows',
|
|
257
|
+
description: `Run a bounded, parameterized read from one catalog-listed table/view in the configured HDI schema. Select 1–${MAX_COLUMNS} known columns; no free-form SQL, DDL, DML, procedure calls, or schema selection is accepted. Maximum ${HANA_MAX_ROWS} rows.`,
|
|
258
|
+
inputSchema: {
|
|
259
|
+
objectName: z.string().trim().min(1).max(127),
|
|
260
|
+
columns: z.array(z.string().trim().min(1).max(127)).min(1).max(MAX_COLUMNS),
|
|
261
|
+
filters: z.array(filterSchema).max(MAX_FILTERS).default([]),
|
|
262
|
+
orderBy: z.object({
|
|
263
|
+
column: z.string().trim().min(1).max(127),
|
|
264
|
+
direction: z.enum(['ASC', 'DESC']).default('ASC')
|
|
265
|
+
}).strict().optional(),
|
|
266
|
+
limit: z.number().int().min(1).max(HANA_MAX_ROWS).default(50)
|
|
267
|
+
},
|
|
268
|
+
annotations: READ_ONLY_ANNOTATIONS
|
|
269
|
+
}, async args => resultOf(async () => readRows(await getDatabase(), schemaName, args)));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export const HANA_TOOL_NAMES = Object.freeze([
|
|
273
|
+
'hana_connection_info',
|
|
274
|
+
'hana_list_objects',
|
|
275
|
+
'hana_describe_object',
|
|
276
|
+
'hana_read_rows'
|
|
277
|
+
]);
|
package/src/mcp-config.mjs
CHANGED
|
@@ -39,6 +39,15 @@ export const SAP_DEVELOPMENT_MCP_SERVERS = Object.freeze([
|
|
|
39
39
|
packageName: '@playwright/mcp',
|
|
40
40
|
bin: 'playwright-mcp',
|
|
41
41
|
priority: 'recommended'
|
|
42
|
+
}),
|
|
43
|
+
Object.freeze({
|
|
44
|
+
id: 'hana-cloud-inspector',
|
|
45
|
+
name: 'HANA Cloud inspector',
|
|
46
|
+
description: 'Read-only HANA Cloud/HDI metadata and bounded row inspection using host-provided HANA_RO_* or VCAP_SERVICES credentials.',
|
|
47
|
+
packageName: 'sap-ai-dev-toolkit',
|
|
48
|
+
bin: 'sap-ai-hana',
|
|
49
|
+
ignoreScripts: true,
|
|
50
|
+
priority: 'optional'
|
|
42
51
|
})
|
|
43
52
|
]);
|
|
44
53
|
|
|
@@ -78,7 +87,7 @@ export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SE
|
|
|
78
87
|
entries[server.id] = {
|
|
79
88
|
type: 'stdio',
|
|
80
89
|
command: packageManager,
|
|
81
|
-
args: ['--yes', `--package=${packageSpec}`, server.bin],
|
|
90
|
+
args: ['--yes', ...(server.ignoreScripts ? ['--ignore-scripts'] : []), `--package=${packageSpec}`, server.bin],
|
|
82
91
|
BAS_EXT: 'true',
|
|
83
92
|
BAS_EXT_KIND: COMPANION_SERVER_KIND,
|
|
84
93
|
displayName: server.name,
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -34,6 +34,66 @@ const APPLICATION_LOG_SCHEMA = {
|
|
|
34
34
|
};
|
|
35
35
|
const APPLICATION_LOG_DESCRIPTION = 'Read SAP application log (SLG1) entries. Results are newest first and limited to 100 by default; set messages=true to include log message details.';
|
|
36
36
|
|
|
37
|
+
// Keep the default public VSP surface small enough for developer-lifecycle use.
|
|
38
|
+
// Hidden upstream tools can still be used by local workflow tools when needed.
|
|
39
|
+
export const PUBLIC_VSP_TOOLS = new Set([
|
|
40
|
+
'Activate',
|
|
41
|
+
'ActivateMultiple',
|
|
42
|
+
'CompareSource',
|
|
43
|
+
'CreateTransport',
|
|
44
|
+
'DebuggerAttach',
|
|
45
|
+
'DebuggerDetach',
|
|
46
|
+
'DebuggerGetStack',
|
|
47
|
+
'DebuggerGetVariables',
|
|
48
|
+
'DebuggerStep',
|
|
49
|
+
'EditSource',
|
|
50
|
+
'FindDefinition',
|
|
51
|
+
'FindReferences',
|
|
52
|
+
'GetAPIReleaseState',
|
|
53
|
+
'GetBreakpoints',
|
|
54
|
+
'GetCDSDependencies',
|
|
55
|
+
'GetCDSElementInfo',
|
|
56
|
+
'GetCDSImpactAnalysis',
|
|
57
|
+
'GetClass',
|
|
58
|
+
'GetClassComponents',
|
|
59
|
+
'GetClassInclude',
|
|
60
|
+
'GetClassInfo',
|
|
61
|
+
'GetConnectionInfo',
|
|
62
|
+
'GetContext',
|
|
63
|
+
'GetFeatures',
|
|
64
|
+
'GetFunction',
|
|
65
|
+
'GetFunctionGroup',
|
|
66
|
+
'GetInactiveObjects',
|
|
67
|
+
'GetInclude',
|
|
68
|
+
'GetInstalledComponents',
|
|
69
|
+
'GetInterface',
|
|
70
|
+
'GetPackage',
|
|
71
|
+
'GetProgram',
|
|
72
|
+
'GetSource',
|
|
73
|
+
'GetSystemInfo',
|
|
74
|
+
'GetTable',
|
|
75
|
+
'GetTableContents',
|
|
76
|
+
'GetTransport',
|
|
77
|
+
'GetTransportInfo',
|
|
78
|
+
'GetUserTransports',
|
|
79
|
+
'GrepObjects',
|
|
80
|
+
'GrepPackages',
|
|
81
|
+
'ListDependencies',
|
|
82
|
+
'ListTransports',
|
|
83
|
+
'PrettyPrint',
|
|
84
|
+
'RunATCCheck',
|
|
85
|
+
'RunQuery',
|
|
86
|
+
'RunUnitTests',
|
|
87
|
+
'SearchObject',
|
|
88
|
+
'SetBreakpoint',
|
|
89
|
+
'SyntaxCheck',
|
|
90
|
+
'WriteSource'
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
function exposeVspTool(tool) {
|
|
94
|
+
return PUBLIC_VSP_TOOLS.has(tool?.name);
|
|
95
|
+
}
|
|
96
|
+
|
|
37
97
|
function applicationLogArguments(arguments_ = {}) {
|
|
38
98
|
const filters = arguments_ && typeof arguments_ === 'object' && !Array.isArray(arguments_) ? arguments_ : {};
|
|
39
99
|
const params = { type: 'application_log' };
|
|
@@ -358,6 +418,7 @@ export class MCPProxy {
|
|
|
358
418
|
});
|
|
359
419
|
}
|
|
360
420
|
|
|
421
|
+
if (!exposeVspTool(tool)) continue;
|
|
361
422
|
const name = `${slug}__${tool.name}`;
|
|
362
423
|
this.namespace.set(name, { entry, upstream: tool.name });
|
|
363
424
|
merged.push({ ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
|
package/src/setup.mjs
CHANGED
|
@@ -306,8 +306,9 @@ export async function runSetup({
|
|
|
306
306
|
let sapDevelopmentServers = [];
|
|
307
307
|
if (includeSapDevelopmentToolsPrompt) {
|
|
308
308
|
print(output, '');
|
|
309
|
-
print(output, formatStatus('Optionally add companion MCP servers for full-stack SAP development.', 'step', output, 'Tools'));
|
|
310
|
-
print(output, '
|
|
309
|
+
print(output, formatStatus('Optionally add companion MCP servers for full-stack SAP development and HANA inspection.', 'step', output, 'Tools'));
|
|
310
|
+
print(output, ' HANA Cloud inspector is read-only and uses HANA_RO_* or VCAP_SERVICES already present in the MCP host environment; credentials are not written to mcp.json.');
|
|
311
|
+
print(output, ' Leave empty to only configure ABAP/ADT destination servers.');
|
|
311
312
|
print(output, '');
|
|
312
313
|
sapDevelopmentServers = await checkboxPrompt({
|
|
313
314
|
message: colorText('🧰 Select companion tools', 'cyan', output),
|