sap-ai-dev-toolkit 0.3.3 → 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/abap-developer.agent.md +3 -1
- package/.github/agents/abap-runtime-debugger.agent.md +3 -1
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +22 -0
- package/.github/agents/rap-service-developer.agent.md +3 -1
- package/.github/agents/sap-solution-architect.agent.md +6 -3
- 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 +50 -22
- package/package.json +9 -4
- package/scripts/postinstall.mjs +3 -3
- package/src/bas-destination-relay.mjs +24 -13
- package/src/credential-overrides.mjs +75 -0
- package/src/credentials-store.mjs +14 -5
- 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/launcher.mjs +10 -32
- package/src/mcp-config.mjs +10 -1
- package/src/mcp-proxy.mjs +61 -0
- package/src/setup.mjs +87 -61
- package/test/bas-destination-relay.test.mjs +50 -0
- package/test/cf-runtime.test.mjs +27 -5
- package/test/copilot-content.test.mjs +63 -0
- package/test/credentials-store.test.mjs +31 -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/launcher.test.mjs +52 -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-cf.test.mjs +131 -4
- package/test/setup.test.mjs +10 -6
- package/tools.md +58 -50
|
@@ -97,16 +97,15 @@ async function readRequestBody(req) {
|
|
|
97
97
|
export function createBasDestinationRelay(destination, { env = process.env, fetchImpl = undiciFetch, log = () => {} } = {}) {
|
|
98
98
|
if (!destination?.url) throw new Error('Destination relay requires a BAS destination URL');
|
|
99
99
|
const base = new URL(destination.url);
|
|
100
|
-
// Self-healing mode 5 (direct connect): the BAS .dest proxy strips
|
|
101
|
-
// Set-Cookie, which makes SAP CSRF session binding impossible. When the
|
|
102
|
-
// operator stored credentials for this destination (setup prompts for
|
|
103
|
-
// Internet destinations), the relay connects straight to the backend host
|
|
104
|
-
// with Basic auth — cookies survive, CSRF pairing works, and writes go
|
|
105
|
-
// through exactly like they do from Eclipse/ADT.
|
|
106
100
|
const credentials = destination.credentials;
|
|
107
|
-
const
|
|
108
|
-
const
|
|
101
|
+
const tunnelMode = credentials?.mode === 'bas-tunnel';
|
|
102
|
+
const directHost = tunnelMode ? '' : credentials?.host;
|
|
103
|
+
// Internet overrides retain the direct-backend path. OnPremise overrides
|
|
104
|
+
// must keep the BAS .dest route (and its Cloud Connector mapping), so they
|
|
105
|
+
// use HTTP CONNECT through the BAS proxy instead of credentials.host.
|
|
106
|
+
const directBase = directHost ? new URL(String(directHost)) : null;
|
|
109
107
|
if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
|
|
108
|
+
if (tunnelMode) log(`[${destination.name}] BAS relay using a BAS proxy tunnel for the OnPremise destination`);
|
|
110
109
|
// Proxy resolution: an explicitly configured proxy wins (an explicit empty
|
|
111
110
|
// value means "go direct"). Without configuration, BAS virtual .dest hosts
|
|
112
111
|
// imply the default BAS proxy; any other host (tests, direct URLs) is
|
|
@@ -114,22 +113,23 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
114
113
|
// foreign proxy.
|
|
115
114
|
const explicitProxy = env.HTTP_PROXY ?? env.http_proxy;
|
|
116
115
|
const proxyUrl = directBase ? '' : (explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : ''));
|
|
116
|
+
if (tunnelMode && !proxyUrl) throw new Error('OnPremise credential overrides require the BAS destination proxy to preserve the Cloud Connector route');
|
|
117
117
|
const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
|
|
118
118
|
// Self-healing mode 2: if the BAS proxy refuses absolute-form proxied
|
|
119
119
|
// requests (its own 502/504), retry through a CONNECT tunnel so the relay
|
|
120
120
|
// keeps working through the same egress.
|
|
121
121
|
const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
|
|
122
|
-
let tunneled =
|
|
122
|
+
let tunneled = tunnelMode;
|
|
123
123
|
// Observable self-healing counters, surfaced by --doctor and tests.
|
|
124
|
-
const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, tunnelFallbacks: 0, direct: Boolean(directBase) };
|
|
124
|
+
const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, tunnelFallbacks: 0, direct: Boolean(directBase), proxyTunnel: tunnelMode };
|
|
125
125
|
// Per-path CSRF sessions: { token, jar, fetchedAt } where jar holds the
|
|
126
126
|
// Set-Cookie state SAP returned together with the token.
|
|
127
127
|
const tokenCache = new Map();
|
|
128
128
|
|
|
129
|
-
function rememberSession(key, token, headers) {
|
|
129
|
+
function rememberSession(key, token, headers, httpStatus) {
|
|
130
130
|
const jar = new Map();
|
|
131
131
|
recordCookies(jar, headers);
|
|
132
|
-
tokenCache.set(key, { token, jar, fetchedAt: Date.now() });
|
|
132
|
+
tokenCache.set(key, { token, jar, fetchedAt: Date.now(), httpStatus });
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
async function send(target, method, headers, body) {
|
|
@@ -171,12 +171,22 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
171
171
|
await response.arrayBuffer().catch(() => undefined);
|
|
172
172
|
const token = response.headers.get('x-csrf-token');
|
|
173
173
|
if (token && token.toLowerCase() !== 'required') {
|
|
174
|
-
rememberSession(key, token, response.headers);
|
|
174
|
+
rememberSession(key, token, response.headers, response.status);
|
|
175
175
|
return tokenCache.get(key);
|
|
176
176
|
}
|
|
177
177
|
return null;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
async function probeCsrfSession(path = '/sap/bc/adt/discovery') {
|
|
181
|
+
const requestUrl = new URL(path, base);
|
|
182
|
+
const session = await fetchToken(requestUrl, { accept: 'application/xml,text/xml,*/*' });
|
|
183
|
+
return {
|
|
184
|
+
httpStatus: session?.httpStatus || 0,
|
|
185
|
+
tokenReceived: Boolean(session?.token),
|
|
186
|
+
cookieCount: session?.jar?.size || 0
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
180
190
|
// Rewrite an incoming .dest target onto the direct backend host when
|
|
181
191
|
// credentials are configured, keeping path and query intact.
|
|
182
192
|
function resolveTarget(target) {
|
|
@@ -261,6 +271,7 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
261
271
|
return {
|
|
262
272
|
ready,
|
|
263
273
|
stats,
|
|
274
|
+
probeCsrfSession,
|
|
264
275
|
close: async () => {
|
|
265
276
|
await dispatcher?.close?.();
|
|
266
277
|
await tunnelDispatcher?.close?.();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readCredentials, resolveCredentialsPath } from './credentials-store.mjs';
|
|
2
|
+
|
|
3
|
+
const CLOUD_FOUNDRY_KEY_PREFIX = 'cloud-foundry:';
|
|
4
|
+
|
|
5
|
+
function isBasicAuthentication(value) {
|
|
6
|
+
return /^basic(?:authentication)?$/i.test(String(value || '').replace(/\s+/g, ''));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function credentialKeyForDestination(destination = {}) {
|
|
10
|
+
if (destination.source === 'cloud-foundry') {
|
|
11
|
+
const serverName = String(destination.serverName || '').trim();
|
|
12
|
+
return serverName ? `${CLOUD_FOUNDRY_KEY_PREFIX}${serverName}` : '';
|
|
13
|
+
}
|
|
14
|
+
return String(destination.name || '').trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function credentialModeForDestination(destination = {}) {
|
|
18
|
+
if (!isBasicAuthentication(destination.authentication)) return null;
|
|
19
|
+
const proxyType = String(destination.proxyType || '').trim().toLowerCase();
|
|
20
|
+
if (destination.source === 'cloud-foundry') {
|
|
21
|
+
return proxyType === 'onpremise' ? 'cf-connectivity' : null;
|
|
22
|
+
}
|
|
23
|
+
if (proxyType === 'internet') return 'direct';
|
|
24
|
+
if (proxyType === 'onpremise') return 'bas-tunnel';
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function canPromptForCredentials(destination = {}) {
|
|
29
|
+
const mode = credentialModeForDestination(destination);
|
|
30
|
+
return Boolean(mode && (mode !== 'direct' || destination.backendUrl));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function enrichWithStoredCredentials(destinations, env = process.env, log = message => console.error(message)) {
|
|
34
|
+
const needingCredentials = destinations.filter(destination => {
|
|
35
|
+
const mode = credentialModeForDestination(destination);
|
|
36
|
+
if (!mode || !canPromptForCredentials(destination)) return false;
|
|
37
|
+
if (mode === 'cf-connectivity') return Boolean(destination.serverName);
|
|
38
|
+
return Boolean(destination.url && String(destination.url).includes('.dest'));
|
|
39
|
+
});
|
|
40
|
+
if (!needingCredentials.length) return destinations;
|
|
41
|
+
let stored;
|
|
42
|
+
try {
|
|
43
|
+
const path = await resolveCredentialsPath(env);
|
|
44
|
+
stored = await readCredentials(path);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
log(`[sap-ai-dev] stored credentials could not be read: ${String(error.message).slice(0, 200)}`);
|
|
47
|
+
return destinations;
|
|
48
|
+
}
|
|
49
|
+
return destinations.map(destination => {
|
|
50
|
+
const mode = credentialModeForDestination(destination);
|
|
51
|
+
const key = credentialKeyForDestination(destination);
|
|
52
|
+
const entry = key ? stored.destinations?.[key] : undefined;
|
|
53
|
+
if (!mode || !entry?.user || !entry?.password) return destination;
|
|
54
|
+
if (mode === 'cf-connectivity') {
|
|
55
|
+
if (entry.mode !== mode || destination.source !== 'cloud-foundry') return destination;
|
|
56
|
+
return {
|
|
57
|
+
...destination,
|
|
58
|
+
childEnv: {
|
|
59
|
+
...(destination.childEnv || {}),
|
|
60
|
+
SAP_USER: entry.user,
|
|
61
|
+
SAP_PASSWORD: entry.password,
|
|
62
|
+
SAP_VERBOSE: 'false'
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (mode === 'bas-tunnel') {
|
|
67
|
+
if (entry.mode !== mode) return destination;
|
|
68
|
+
return { ...destination, credentials: { user: entry.user, password: entry.password, mode } };
|
|
69
|
+
}
|
|
70
|
+
if (!destination.backendUrl || !destination.url || !String(destination.url).includes('.dest')) return destination;
|
|
71
|
+
if (entry.mode && entry.mode !== 'direct') return destination;
|
|
72
|
+
const host = entry.host || destination.backendUrl;
|
|
73
|
+
return { ...destination, credentials: { host, user: entry.user, password: entry.password, mode: 'direct' } };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -4,10 +4,11 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { basename, dirname, join } from 'node:path';
|
|
5
5
|
import { brandedEnvValue } from './branding.mjs';
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
// (never inside it), with 0600 permissions and no tokens or cookies.
|
|
9
|
-
//
|
|
7
|
+
// Optional SAP backend credential overrides live next to the MCP config
|
|
8
|
+
// (never inside it), with 0600 permissions and no tokens or cookies. Entries
|
|
9
|
+
// are keyed by a stable destination identity and may include a route mode.
|
|
10
10
|
const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
|
|
11
|
+
const CREDENTIAL_MODES = new Set(['direct', 'bas-tunnel', 'cf-connectivity']);
|
|
11
12
|
|
|
12
13
|
const SECRET_PATTERN = /(authorization|cookie|password|secret|token|bearer|credential)/i;
|
|
13
14
|
|
|
@@ -44,6 +45,7 @@ function sanitizeEntry(entry) {
|
|
|
44
45
|
if (typeof entry?.host === 'string' && entry.host) result.host = entry.host;
|
|
45
46
|
if (typeof entry?.user === 'string' && entry.user) result.user = entry.user;
|
|
46
47
|
if (typeof entry?.password === 'string' && entry.password) result.password = entry.password;
|
|
48
|
+
if (CREDENTIAL_MODES.has(entry?.mode)) result.mode = entry.mode;
|
|
47
49
|
if (typeof entry?.updatedAt === 'string') result.updatedAt = entry.updatedAt;
|
|
48
50
|
return result;
|
|
49
51
|
}
|
|
@@ -87,10 +89,17 @@ async function writeCredentials(path, destinations) {
|
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
export async function storeDestinationCredentials(path, name, { host, user, password }) {
|
|
92
|
+
export async function storeDestinationCredentials(path, name, { host, user, password, mode }) {
|
|
93
|
+
if (mode != null && !CREDENTIAL_MODES.has(mode)) throw new Error('Credential override route mode is unsupported');
|
|
91
94
|
const current = await readCredentials(path).catch(error => { throw error; });
|
|
92
95
|
const destinations = { ...current.destinations };
|
|
93
|
-
destinations[String(name)] = {
|
|
96
|
+
destinations[String(name)] = {
|
|
97
|
+
host: String(host || ''),
|
|
98
|
+
user: String(user || ''),
|
|
99
|
+
password: String(password || ''),
|
|
100
|
+
...(mode ? { mode } : {}),
|
|
101
|
+
updatedAt: new Date().toISOString()
|
|
102
|
+
};
|
|
94
103
|
await writeCredentials(path, destinations);
|
|
95
104
|
return { path, name };
|
|
96
105
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const EXPLICIT_CONNECTION_KEYS = [
|
|
2
|
+
'HANA_RO_HOST',
|
|
3
|
+
'HANA_RO_PORT',
|
|
4
|
+
'HANA_RO_USER',
|
|
5
|
+
'HANA_RO_PASSWORD',
|
|
6
|
+
'HANA_RO_SCHEMA'
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SERVICE_TYPES = new Set(['hana', 'hana-cloud', 'hana-cloud-db', 'hanacloud', 'sap-hana-cloud']);
|
|
10
|
+
|
|
11
|
+
function fail(message) {
|
|
12
|
+
throw new Error(message);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeServiceType(value) {
|
|
16
|
+
return String(value || '').trim().toLowerCase().replaceAll('_', '-');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function requiredText(value, label) {
|
|
20
|
+
if (typeof value !== 'string' || !value.trim()) fail(`HANA read-only connection is missing ${label}.`);
|
|
21
|
+
return value.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validateHost(value) {
|
|
25
|
+
const host = requiredText(value, 'host');
|
|
26
|
+
if (/[\s/@?#\\]/.test(host) || host.includes('://') || host.includes(':')) {
|
|
27
|
+
fail('HANA_RO_HOST must contain only a hostname; provide the port separately.');
|
|
28
|
+
}
|
|
29
|
+
return host;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validatePort(value) {
|
|
33
|
+
const portText = requiredText(String(value ?? ''), 'port');
|
|
34
|
+
if (!/^\d{1,5}$/.test(portText)) fail('HANA_RO_PORT must be an integer between 1 and 65535.');
|
|
35
|
+
const port = Number(portText);
|
|
36
|
+
if (port < 1 || port > 65535) fail('HANA_RO_PORT must be an integer between 1 and 65535.');
|
|
37
|
+
return port;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validateSchema(value) {
|
|
41
|
+
const schema = requiredText(value, 'schema');
|
|
42
|
+
if (schema.length > 127 || /[\u0000-\u001f\u007f]/.test(schema)) {
|
|
43
|
+
fail('The configured HANA schema name is invalid.');
|
|
44
|
+
}
|
|
45
|
+
return schema;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasExplicitConnection(env) {
|
|
49
|
+
return EXPLICIT_CONNECTION_KEYS.some(key => env[key] !== undefined && env[key] !== '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function explicitConfig(env) {
|
|
53
|
+
const host = validateHost(env.HANA_RO_HOST);
|
|
54
|
+
const port = validatePort(env.HANA_RO_PORT);
|
|
55
|
+
const user = requiredText(env.HANA_RO_USER, 'read-only user');
|
|
56
|
+
const password = requiredText(env.HANA_RO_PASSWORD, 'read-only password');
|
|
57
|
+
const schema = validateSchema(env.HANA_RO_SCHEMA);
|
|
58
|
+
return {
|
|
59
|
+
source: 'environment',
|
|
60
|
+
bindingName: undefined,
|
|
61
|
+
serviceName: undefined,
|
|
62
|
+
host,
|
|
63
|
+
port,
|
|
64
|
+
user,
|
|
65
|
+
password,
|
|
66
|
+
schema,
|
|
67
|
+
trustStore: optionalTrustStore(env.HANA_RO_TRUST_STORE)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function optionalTrustStore(value) {
|
|
72
|
+
if (value === undefined || value === '') return undefined;
|
|
73
|
+
const trustStore = requiredText(value, 'TLS trust-store path');
|
|
74
|
+
if (/[\u0000-\u001f\u007f]/.test(trustStore)) fail('The configured HANA TLS trust-store path is invalid.');
|
|
75
|
+
return trustStore;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseVcapServices(raw) {
|
|
79
|
+
if (!raw) fail('Set HANA_RO_* read-only credentials or bind a selected HANA service in VCAP_SERVICES.');
|
|
80
|
+
let services;
|
|
81
|
+
try {
|
|
82
|
+
services = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
83
|
+
} catch {
|
|
84
|
+
fail('VCAP_SERVICES is not valid JSON.');
|
|
85
|
+
}
|
|
86
|
+
if (!services || typeof services !== 'object' || Array.isArray(services)) {
|
|
87
|
+
fail('VCAP_SERVICES must contain a JSON object.');
|
|
88
|
+
}
|
|
89
|
+
return services;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function vcapCandidates(env) {
|
|
93
|
+
const services = parseVcapServices(env.VCAP_SERVICES);
|
|
94
|
+
const selectedType = env.HANA_RO_VCAP_SERVICE?.trim();
|
|
95
|
+
const bindingName = env.HANA_RO_BINDING?.trim();
|
|
96
|
+
const entries = Object.entries(services).filter(([serviceType]) => {
|
|
97
|
+
return selectedType
|
|
98
|
+
? serviceType === selectedType
|
|
99
|
+
: DEFAULT_SERVICE_TYPES.has(normalizeServiceType(serviceType));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (selectedType && !entries.length) fail('The selected HANA VCAP service type was not found.');
|
|
103
|
+
|
|
104
|
+
let candidates = entries.flatMap(([serviceName, instances]) => {
|
|
105
|
+
if (!Array.isArray(instances)) return [];
|
|
106
|
+
return instances
|
|
107
|
+
.filter(instance => instance && typeof instance === 'object' && !Array.isArray(instance))
|
|
108
|
+
.map(instance => ({ serviceName, instance }));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (bindingName) {
|
|
112
|
+
candidates = candidates.filter(({ instance }) => {
|
|
113
|
+
return instance.name === bindingName || instance.binding_name === bindingName;
|
|
114
|
+
});
|
|
115
|
+
if (!candidates.length) fail('The selected HANA binding was not found in VCAP_SERVICES.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (candidates.length !== 1) {
|
|
119
|
+
fail(candidates.length === 0
|
|
120
|
+
? 'No HANA VCAP binding was found; set HANA_RO_VCAP_SERVICE or explicit HANA_RO_* credentials.'
|
|
121
|
+
: 'Multiple HANA VCAP bindings were found; set HANA_RO_BINDING to select exactly one.');
|
|
122
|
+
}
|
|
123
|
+
return candidates[0];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function vcapConfig(env) {
|
|
127
|
+
const { serviceName, instance } = vcapCandidates(env);
|
|
128
|
+
const credentials = instance.credentials;
|
|
129
|
+
if (!credentials || typeof credentials !== 'object' || Array.isArray(credentials)) {
|
|
130
|
+
fail('The selected HANA binding has no usable credentials object.');
|
|
131
|
+
}
|
|
132
|
+
if ((!credentials.user || !credentials.password) && (credentials.hdi_user || credentials.hdi_password)) {
|
|
133
|
+
fail('The selected HANA binding only exposes HDI deployment credentials; configure a separate read-only binding.');
|
|
134
|
+
}
|
|
135
|
+
if (credentials.user && credentials.hdi_user && credentials.user === credentials.hdi_user) {
|
|
136
|
+
fail('The selected HANA binding aliases its read-only and HDI deployment users; configure separate identities.');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
source: 'vcap-services',
|
|
141
|
+
bindingName: typeof instance.binding_name === 'string' ? instance.binding_name : instance.name,
|
|
142
|
+
serviceName,
|
|
143
|
+
host: validateHost(credentials.host),
|
|
144
|
+
port: validatePort(credentials.port),
|
|
145
|
+
user: requiredText(credentials.user, 'read-only user'),
|
|
146
|
+
password: requiredText(credentials.password, 'read-only password'),
|
|
147
|
+
schema: validateSchema(credentials.schema),
|
|
148
|
+
trustStore: optionalTrustStore(env.HANA_RO_TRUST_STORE)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Resolve only a read-only HANA identity; HDI deployment credentials are never a fallback. */
|
|
153
|
+
export function resolveHanaReadOnlyConfig(env = process.env) {
|
|
154
|
+
if (!env || typeof env !== 'object') fail('HANA environment configuration is unavailable.');
|
|
155
|
+
const hasExplicit = hasExplicitConnection(env);
|
|
156
|
+
if (hasExplicit) {
|
|
157
|
+
const missing = EXPLICIT_CONNECTION_KEYS.filter(key => typeof env[key] !== 'string' || !env[key].trim());
|
|
158
|
+
if (missing.length) fail(`Explicit HANA read-only configuration is incomplete: ${missing.join(', ')}.`);
|
|
159
|
+
return Object.freeze(explicitConfig(env));
|
|
160
|
+
}
|
|
161
|
+
return Object.freeze(vcapConfig(env));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function quoteHanaIdentifier(value) {
|
|
165
|
+
const identifier = requiredText(value, 'identifier');
|
|
166
|
+
if (identifier.length > 127 || /[\u0000-\u001f\u007f]/.test(identifier)) {
|
|
167
|
+
fail('The HANA identifier is invalid.');
|
|
168
|
+
}
|
|
169
|
+
return `"${identifier.replaceAll('"', '""')}"`;
|
|
170
|
+
}
|
|
@@ -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
|
+
}
|