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.
Files changed (36) hide show
  1. package/.github/agents/abap-developer.agent.md +3 -1
  2. package/.github/agents/abap-runtime-debugger.agent.md +3 -1
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +22 -0
  4. package/.github/agents/rap-service-developer.agent.md +3 -1
  5. package/.github/agents/sap-solution-architect.agent.md +6 -3
  6. package/.github/skills/hana-cloud-inspection/SKILL.md +35 -0
  7. package/.github/skills/hana-cloud-native-development/SKILL.md +26 -0
  8. package/.github/skills/hana-cloud-validation/SKILL.md +26 -0
  9. package/README.md +50 -22
  10. package/package.json +9 -4
  11. package/scripts/postinstall.mjs +3 -3
  12. package/src/bas-destination-relay.mjs +24 -13
  13. package/src/credential-overrides.mjs +75 -0
  14. package/src/credentials-store.mjs +14 -5
  15. package/src/hana-config.mjs +170 -0
  16. package/src/hana-database.mjs +150 -0
  17. package/src/hana-inspector.mjs +114 -0
  18. package/src/hana-tools.mjs +277 -0
  19. package/src/launcher.mjs +10 -32
  20. package/src/mcp-config.mjs +10 -1
  21. package/src/mcp-proxy.mjs +61 -0
  22. package/src/setup.mjs +87 -61
  23. package/test/bas-destination-relay.test.mjs +50 -0
  24. package/test/cf-runtime.test.mjs +27 -5
  25. package/test/copilot-content.test.mjs +63 -0
  26. package/test/credentials-store.test.mjs +31 -0
  27. package/test/hana-config.test.mjs +168 -0
  28. package/test/hana-inspector-stdio.test.mjs +44 -0
  29. package/test/hana-tools.test.mjs +206 -0
  30. package/test/launcher.test.mjs +52 -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-cf.test.mjs +131 -4
  35. package/test/setup.test.mjs +10 -6
  36. 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 directBase = credentials?.host ? new URL(String(credentials.host)) : null;
108
- const effectiveBase = directBase || base;
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 = false;
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
- // Credentials for direct-connect destinations live next to the MCP config
8
- // (never inside it), with 0600 permissions and no tokens or cookies. The
9
- // file maps destination names to { host, user, password, updatedAt }.
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)] = { host: String(host || ''), user: String(user || ''), password: String(password || ''), updatedAt: new Date().toISOString() };
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
+ }