sap-ai-dev-toolkit 0.9.9922 → 0.9.9924

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 (62) hide show
  1. package/.github/agents/sap-btp-infrastructure-admin.agent.md +23 -0
  2. package/.github/agents/sap-bw-datasphere-migration-specialist.agent.md +22 -0
  3. package/.github/agents/sap-datasphere-developer.agent.md +22 -0
  4. package/.github/agents/sap-devops-observability-specialist.agent.md +54 -0
  5. package/.github/agents/sap-process-automation-developer.agent.md +50 -0
  6. package/.github/skills/btp-platform-administration/SKILL.md +53 -0
  7. package/.github/skills/bw-datasphere-migration/SKILL.md +66 -0
  8. package/.github/skills/cloud-alm-operations/SKILL.md +135 -0
  9. package/.github/skills/datasphere-administration/SKILL.md +40 -0
  10. package/.github/skills/datasphere-development/SKILL.md +55 -0
  11. package/.github/skills/process-automation-development/SKILL.md +113 -0
  12. package/NOTICE +2 -1
  13. package/README.md +178 -12
  14. package/dist/checksums.txt +5 -5
  15. package/dist/vsp-darwin-arm64 +0 -0
  16. package/dist/vsp-darwin-x64 +0 -0
  17. package/dist/vsp-linux-arm64 +0 -0
  18. package/dist/vsp-linux-x64 +0 -0
  19. package/dist/vsp-win32-x64.exe +0 -0
  20. package/inventory.md +60 -0
  21. package/package.json +8 -2
  22. package/patches/vsp-single-logon.patch +258 -0
  23. package/scripts/build-vsp.mjs +1 -1
  24. package/src/btp-cli.mjs +199 -0
  25. package/src/btp-server.mjs +125 -0
  26. package/src/btp-tools.mjs +776 -0
  27. package/src/cloud-alm-client.mjs +44 -0
  28. package/src/cloud-alm-config.mjs +92 -0
  29. package/src/cloud-alm-credentials.mjs +29 -0
  30. package/src/cloud-alm-data.mjs +129 -0
  31. package/src/cloud-alm-server.mjs +115 -0
  32. package/src/cloud-alm-tools.mjs +682 -0
  33. package/src/datasphere-auth.mjs +279 -0
  34. package/src/datasphere-cli.mjs +220 -0
  35. package/src/datasphere-command.mjs +236 -0
  36. package/src/datasphere-config.mjs +154 -0
  37. package/src/datasphere-credentials.mjs +37 -0
  38. package/src/datasphere-csn.mjs +73 -0
  39. package/src/datasphere-discovery.mjs +151 -0
  40. package/src/datasphere-server.mjs +249 -0
  41. package/src/datasphere-tools.mjs +809 -0
  42. package/src/hana-database.mjs +3 -3
  43. package/src/hana-inspector.mjs +2 -2
  44. package/src/harnesses.mjs +31 -1
  45. package/src/install-user-assets.mjs +1 -1
  46. package/src/integration-common.mjs +7 -7
  47. package/src/integration-config.mjs +5 -0
  48. package/src/integration-credentials.mjs +25 -16
  49. package/src/integration-http.mjs +24 -7
  50. package/src/launcher.mjs +56 -4
  51. package/src/mcp-config.mjs +37 -1
  52. package/src/process-automation-automation-tools.mjs +125 -0
  53. package/src/process-automation-client.mjs +36 -0
  54. package/src/process-automation-common.mjs +69 -0
  55. package/src/process-automation-config.mjs +142 -0
  56. package/src/process-automation-credentials.mjs +37 -0
  57. package/src/process-automation-inbox-tools.mjs +246 -0
  58. package/src/process-automation-server.mjs +139 -0
  59. package/src/process-automation-tools.mjs +727 -0
  60. package/src/process-automation-visibility-tools.mjs +182 -0
  61. package/src/setup.mjs +623 -11
  62. package/tools.md +138 -0
@@ -0,0 +1,258 @@
1
+ diff --git a/pkg/adt/http.go b/pkg/adt/http.go
2
+ index e420573..5386a26 100755
3
+ --- a/pkg/adt/http.go
4
+ +++ b/pkg/adt/http.go
5
+ @@ -12,6 +12,7 @@ import (
6
+ "os"
7
+ "strings"
8
+ "sync"
9
+ + "sync/atomic"
10
+ "time"
11
+ )
12
+
13
+ @@ -135,6 +136,10 @@ type Transport struct {
14
+ // from triggering simultaneous SAML dances.
15
+ reauthMu sync.Mutex
16
+ lastReauth time.Time
17
+ +
18
+ + // Logon single-flight: see http_logon_gate.go.
19
+ + logonSlot chan struct{}
20
+ + logonSettled atomic.Bool
21
+ }
22
+
23
+ // NewTransport creates a new Transport with the given configuration.
24
+ @@ -149,6 +154,7 @@ func NewTransportWithClient(cfg *Config, client HTTPDoer) *Transport {
25
+ t := &Transport{
26
+ config: cfg,
27
+ httpClient: client,
28
+ + logonSlot: make(chan struct{}, 1),
29
+ }
30
+ if cfg.Cache {
31
+ t.cache = newResponseCache(cfg.CacheStore, cfg.CacheTTL)
32
+ @@ -212,10 +218,10 @@ func (t *Transport) Request(ctx context.Context, path string, opts *RequestOptio
33
+ opts.Method = http.MethodGet
34
+ }
35
+ if t.cache == nil {
36
+ - return t.request(ctx, path, opts)
37
+ + return t.gatedRequest(ctx, path, opts)
38
+ }
39
+ if !cacheable(path, opts) {
40
+ - resp, err := t.request(ctx, path, opts)
41
+ + resp, err := t.gatedRequest(ctx, path, opts)
42
+ if isModifyingMethod(opts.Method) && !strings.HasPrefix(path, "/sap/bc/adt/datapreview/") {
43
+ t.cache.invalidate()
44
+ }
45
+ @@ -229,7 +235,7 @@ func (t *Transport) Request(ctx context.Context, path string, opts *RequestOptio
46
+ if resp, ok := t.cache.get(key); ok {
47
+ return resp, nil
48
+ }
49
+ - resp, err := t.request(ctx, path, opts)
50
+ + resp, err := t.gatedRequest(ctx, path, opts)
51
+ if err == nil && resp != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
52
+ t.cache.put(key, resp)
53
+ }
54
+ @@ -1062,6 +1068,8 @@ func (t *Transport) resetCookieJar() {
55
+ }
56
+ if jar, err := cookiejar.New(nil); err == nil {
57
+ client.Jar = jar
58
+ + // The new jar holds no session: the next requests must share one logon again.
59
+ + t.logonSettled.Store(false)
60
+ }
61
+ }
62
+
63
+ diff --git a/pkg/adt/http_logon_gate.go b/pkg/adt/http_logon_gate.go
64
+ new file mode 100644
65
+ index 0000000..9e71c60
66
+ --- /dev/null
67
+ +++ b/pkg/adt/http_logon_gate.go
68
+ @@ -0,0 +1,76 @@
69
+ +package adt
70
+ +
71
+ +import (
72
+ + "context"
73
+ + "net/http"
74
+ + "net/url"
75
+ + "strings"
76
+ +)
77
+ +
78
+ +// Every request that reaches SAP without a security-session cookie is a new
79
+ +// logon: SAP authenticates it and opens a security session of its own, which
80
+ +// lingers in SM04 until it times out. MCP clients fan tool calls out in
81
+ +// parallel, so a cold transport used to send each of them without a cookie
82
+ +// and opened one session per call (dozens per burst for one user).
83
+ +//
84
+ +// gatedRequest lets exactly one request log on while the jar holds no session
85
+ +// cookie; the others wait for it and then reuse the cookie it brought back.
86
+ +// When that request succeeds without the server issuing a session cookie
87
+ +// (security sessions off, or a proxy chain that keeps them), the gate settles
88
+ +// open so requests are never serialized for nothing. Dropping the jar after an
89
+ +// expired session re-arms it.
90
+ +func (t *Transport) gatedRequest(ctx context.Context, path string, opts *RequestOptions) (*Response, error) {
91
+ + if t.logonSlot == nil || t.logonSettled.Load() || t.hasSessionCookie() {
92
+ + return t.request(ctx, path, opts)
93
+ + }
94
+ + select {
95
+ + case t.logonSlot <- struct{}{}:
96
+ + case <-ctx.Done():
97
+ + return nil, ctx.Err()
98
+ + }
99
+ + if t.logonSettled.Load() || t.hasSessionCookie() {
100
+ + <-t.logonSlot
101
+ + return t.request(ctx, path, opts)
102
+ + }
103
+ + resp, err := t.request(ctx, path, opts)
104
+ + if err == nil && !t.hasSessionCookie() {
105
+ + t.logonSettled.Store(true)
106
+ + }
107
+ + <-t.logonSlot
108
+ + return resp, err
109
+ +}
110
+ +
111
+ +// hasSessionCookie reports whether requests to the SAP system already carry
112
+ +// a security session: one the jar learned from the server, or one the caller
113
+ +// supplied for cookie authentication.
114
+ +func (t *Transport) hasSessionCookie() bool {
115
+ + t.cookiesMu.RLock()
116
+ + for name := range t.config.Cookies {
117
+ + if isSessionCookieName(name) {
118
+ + t.cookiesMu.RUnlock()
119
+ + return true
120
+ + }
121
+ + }
122
+ + t.cookiesMu.RUnlock()
123
+ + client, ok := t.httpClient.(*http.Client)
124
+ + if !ok || client.Jar == nil {
125
+ + return false
126
+ + }
127
+ + base, err := url.Parse(strings.TrimRight(t.config.BaseURL, "/") + "/sap/bc/adt/")
128
+ + if err != nil {
129
+ + return false
130
+ + }
131
+ + for _, cookie := range client.Jar.Cookies(base) {
132
+ + if isSessionCookieName(cookie.Name) {
133
+ + return true
134
+ + }
135
+ + }
136
+ + return false
137
+ +}
138
+ +
139
+ +// isSessionCookieName matches the ICF security-session cookie
140
+ +// (SAP_SESSIONID_<SID>_<client>). A MYSAPSSO2 logon ticket does not count:
141
+ +// every request presenting only the ticket opens another session.
142
+ +func isSessionCookieName(name string) bool {
143
+ + return strings.HasPrefix(name, "SAP_SESSIONID")
144
+ +}
145
+ diff --git a/pkg/adt/http_logon_gate_test.go b/pkg/adt/http_logon_gate_test.go
146
+ new file mode 100644
147
+ index 0000000..e973790
148
+ --- /dev/null
149
+ +++ b/pkg/adt/http_logon_gate_test.go
150
+ @@ -0,0 +1,108 @@
151
+ +package adt
152
+ +
153
+ +import (
154
+ + "context"
155
+ + "fmt"
156
+ + "net/http"
157
+ + "net/http/httptest"
158
+ + "sync"
159
+ + "sync/atomic"
160
+ + "testing"
161
+ + "time"
162
+ +)
163
+ +
164
+ +// sapSessionServer mimics ICF security sessions: a request without a live
165
+ +// SAP_SESSIONID cookie is a logon and, when issueCookies, gets a new one.
166
+ +type sapSessionServer struct {
167
+ + issueCookies bool
168
+ + logons atomic.Int32
169
+ + inFlight atomic.Int32
170
+ + peak atomic.Int32
171
+ + mu sync.Mutex
172
+ + live map[string]bool
173
+ +}
174
+ +
175
+ +func (s *sapSessionServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
176
+ + now := s.inFlight.Add(1)
177
+ + defer s.inFlight.Add(-1)
178
+ + for {
179
+ + peak := s.peak.Load()
180
+ + if now <= peak || s.peak.CompareAndSwap(peak, now) {
181
+ + break
182
+ + }
183
+ + }
184
+ + s.mu.Lock()
185
+ + cookie, err := r.Cookie("SAP_SESSIONID_T4D_100")
186
+ + if err != nil || !s.live[cookie.Value] {
187
+ + id := fmt.Sprintf("S%d", s.logons.Add(1))
188
+ + if s.issueCookies {
189
+ + s.live[id] = true
190
+ + http.SetCookie(w, &http.Cookie{Name: "SAP_SESSIONID_T4D_100", Value: id, Path: "/"})
191
+ + }
192
+ + }
193
+ + s.mu.Unlock()
194
+ + w.Header().Add("Set-Cookie", "sap-usercontext=sap-client=100; path=/")
195
+ + time.Sleep(30 * time.Millisecond)
196
+ + fmt.Fprint(w, "ok")
197
+ +}
198
+ +
199
+ +func newSessionTransport(t *testing.T, issueCookies bool) (*Transport, *sapSessionServer) {
200
+ + t.Helper()
201
+ + sap := &sapSessionServer{issueCookies: issueCookies, live: map[string]bool{}}
202
+ + server := httptest.NewServer(sap)
203
+ + t.Cleanup(server.Close)
204
+ + return NewTransport(NewConfig(server.URL, "EMSDI0845", "secret", WithClient("100"))), sap
205
+ +}
206
+ +
207
+ +func burst(t *testing.T, transport *Transport, n int) {
208
+ + t.Helper()
209
+ + var wg sync.WaitGroup
210
+ + errs := make(chan error, n)
211
+ + for i := range n {
212
+ + wg.Add(1)
213
+ + go func() {
214
+ + defer wg.Done()
215
+ + if _, err := transport.Request(context.Background(), fmt.Sprintf("/sap/bc/adt/oo/classes/ZCL_%d/source/main", i), nil); err != nil {
216
+ + errs <- err
217
+ + }
218
+ + }()
219
+ + }
220
+ + wg.Wait()
221
+ + close(errs)
222
+ + for err := range errs {
223
+ + t.Fatalf("request failed: %v", err)
224
+ + }
225
+ +}
226
+ +
227
+ +func TestTransport_ConcurrentColdRequestsShareOneLogon(t *testing.T) {
228
+ + transport, sap := newSessionTransport(t, true)
229
+ +
230
+ + burst(t, transport, 12)
231
+ + burst(t, transport, 12)
232
+ +
233
+ + if got := sap.logons.Load(); got != 1 {
234
+ + t.Fatalf("logons = %d, want 1: parallel tool calls must reuse one SAP security session", got)
235
+ + }
236
+ +}
237
+ +
238
+ +func TestTransport_LogonGateOpensWhenServerKeepsNoSession(t *testing.T) {
239
+ + transport, sap := newSessionTransport(t, false)
240
+ +
241
+ + burst(t, transport, 12)
242
+ +
243
+ + if peak := sap.peak.Load(); peak < 2 {
244
+ + t.Fatalf("peak concurrency = %d: without session cookies the gate must not serialize every request", peak)
245
+ + }
246
+ +}
247
+ +
248
+ +func TestTransport_LogonGateRearmsAfterJarReset(t *testing.T) {
249
+ + transport, sap := newSessionTransport(t, true)
250
+ +
251
+ + burst(t, transport, 6)
252
+ + transport.resetCookieJar()
253
+ + burst(t, transport, 6)
254
+ +
255
+ + if got := sap.logons.Load(); got != 2 {
256
+ + t.Fatalf("logons = %d, want 2 (one per session)", got)
257
+ + }
258
+ +}
@@ -33,7 +33,7 @@ async function main() {
33
33
  await mkdir(dirname(source), { recursive: true });
34
34
  await run('git', ['clone', '--filter=blob:none', upstream, source]);
35
35
  await run('git', ['-C', source, 'checkout', '--detach', commit]);
36
- for (const patchFile of ['vsp-bas-proxy-auth.patch', 'vsp-namespaced-class-include-edit.patch']) {
36
+ for (const patchFile of ['vsp-bas-proxy-auth.patch', 'vsp-namespaced-class-include-edit.patch', 'vsp-single-logon.patch']) {
37
37
  await run('patch', ['-p1', '-i', join(root, 'patches', patchFile)], { cwd: source });
38
38
  }
39
39
  await mkdir(dist, { recursive: true });
@@ -0,0 +1,199 @@
1
+ // Child-process runner for the SAP BTP CLI. It uses the user's own
2
+ // `btp login --sso` session from the CLI configuration and never logs in by
3
+ // itself (except loginBtpCli, called from the setup wizard). It never runs
4
+ // `btp target`; every subaccount-scoped call passes --subaccount. Calls are
5
+ // serialized so session refreshes inside the CLI cannot race.
6
+ import { execFile, spawn } from 'node:child_process';
7
+ import { constants, readFileSync } from 'node:fs';
8
+ import { access } from 'node:fs/promises';
9
+ import { delimiter, resolve } from 'node:path';
10
+ import { IntegrationError } from './integration-http.mjs';
11
+ import { diagnosticText } from './redact.mjs';
12
+ import { userConfigFilePath, writeUserOnlyJson } from './integration-credentials.mjs';
13
+
14
+ const CLI_TIMEOUT_MS = 120_000;
15
+ // Global-account entitlement JSON carries an iconBase64 per service.
16
+ const MAX_BUFFER = 32 * 1024 * 1024;
17
+ const ERROR_CAP = 800;
18
+ const LOGIN_REQUIRED = /Unknown session|Please log in|log in again|session (?:has )?expired/i;
19
+
20
+ export const BTP_LOGIN_REQUIRED_MESSAGE = 'The btp CLI session is missing or expired (sessions last at most 12 hours). Run "btp login --sso" in a terminal, or sap-ai-dev --setup-btp, then retry.';
21
+
22
+ /** Parse btp CLI JSON output; tolerates notices around the JSON. null for empty output, undefined when not JSON. */
23
+ export function parseCliJson(text) {
24
+ const trimmed = String(text ?? '').trim();
25
+ if (!trimmed) return null;
26
+ try {
27
+ return JSON.parse(trimmed);
28
+ } catch {
29
+ // Retry on the outermost JSON value below.
30
+ }
31
+ const starts = [trimmed.indexOf('{'), trimmed.indexOf('[')].filter(index => index >= 0);
32
+ const end = Math.max(trimmed.lastIndexOf('}'), trimmed.lastIndexOf(']'));
33
+ if (!starts.length || end < 0) return undefined;
34
+ const start = Math.min(...starts);
35
+ if (end <= start) return undefined;
36
+ try {
37
+ return JSON.parse(trimmed.slice(start, end + 1));
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ export function btpSettingsPath(env = process.env) {
44
+ return userConfigFilePath(env, 'btp.json');
45
+ }
46
+
47
+ /** Stored { cli?, config? } written by sap-ai-dev --setup-btp; {} when absent. */
48
+ export function readBtpSettings(env = process.env) {
49
+ const path = btpSettingsPath(env);
50
+ let text;
51
+ try {
52
+ text = readFileSync(path, 'utf8');
53
+ } catch (error) {
54
+ if (error?.code === 'ENOENT') return {};
55
+ throw new Error(`Stored btp CLI settings at ${path} could not be read`);
56
+ }
57
+ try {
58
+ return JSON.parse(text);
59
+ } catch {
60
+ throw new Error(`Stored btp CLI settings at ${path} are not valid JSON; rerun sap-ai-dev --setup-btp`);
61
+ }
62
+ }
63
+
64
+ export async function storeBtpSettings(settings, env = process.env) {
65
+ return writeUserOnlyJson(btpSettingsPath(env), settings);
66
+ }
67
+
68
+ const trimmed = value => (typeof value === 'string' ? value.trim() : '');
69
+
70
+ export function resolveBtpCliSettings(env = process.env, { stored = readBtpSettings(env) } = {}) {
71
+ const fromEnv = trimmed(env.BTP_CLI);
72
+ const fromStore = trimmed(stored?.cli);
73
+ const cli = fromEnv || fromStore || 'btp';
74
+ const cliSource = fromEnv ? 'environment' : fromStore ? 'stored' : 'PATH';
75
+ const config = trimmed(env.BTP_CLIENTCONFIG) || trimmed(stored?.config) || null;
76
+ return Object.freeze({ cli, cliSource, config });
77
+ }
78
+
79
+ /** Absolute path of the btp executable on PATH, or null. */
80
+ export async function locateBtpCli(env = process.env) {
81
+ const executableNames = process.platform === 'win32' ? ['btp.exe', 'btp'] : ['btp'];
82
+ const searchPath = String(env.PATH || env.Path || '').split(delimiter).filter(Boolean);
83
+ for (const directory of searchPath) {
84
+ for (const executableName of executableNames) {
85
+ const candidate = resolve(directory, executableName);
86
+ try {
87
+ await access(candidate, constants.X_OK);
88
+ return candidate;
89
+ } catch {
90
+ // Continue searching PATH.
91
+ }
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ const childEnvironment = (env, config) => (config ? { ...env, BTP_CLIENTCONFIG: config } : env);
98
+
99
+ const missingCliError = cli => new IntegrationError(
100
+ `btp CLI not found (${cli}). Install the SAP BTP command line interface from https://tools.hana.ondemand.com/#cloud, then rerun sap-ai-dev --setup-btp or set BTP_CLI to its path.`,
101
+ { code: 'BTP_CLI_MISSING' }
102
+ );
103
+
104
+ function flatten(flags = {}) {
105
+ const argv = [];
106
+ for (const [name, value] of Object.entries(flags)) {
107
+ if (value === undefined || value === null || value === false) continue;
108
+ if (value === true) argv.push(`--${name}`);
109
+ else argv.push(`--${name}`, String(value));
110
+ }
111
+ return argv;
112
+ }
113
+
114
+ function errorDetail(stderr, stdout, error) {
115
+ const raw = String(stderr || '').trim() || String(stdout || '').trim() || String(error?.message || '');
116
+ const lines = raw.split(/\r?\n/)
117
+ .map(line => line.trim())
118
+ .filter(line => line && !/^(?:✗ ERROR|ERROR|OK)$/.test(line) && !line.startsWith('SAP BTP command line interface'));
119
+ return diagnosticText(lines.join(' '), ERROR_CAP);
120
+ }
121
+
122
+ export function createBtpCli({ cli, config = null, env = process.env, execFileImpl = execFile } = {}) {
123
+ if (!cli) throw new Error('btp CLI runner needs the btp executable');
124
+ const childEnv = childEnvironment(env, config);
125
+ let queue = Promise.resolve();
126
+
127
+ const enqueue = operation => {
128
+ const result = queue.then(operation);
129
+ queue = result.then(() => undefined, () => undefined);
130
+ return result;
131
+ };
132
+
133
+ const exec = (words, argv) => enqueue(() => new Promise((resolvePromise, reject) => {
134
+ const label = words.join(' ');
135
+ const child = execFileImpl(cli, argv, {
136
+ env: childEnv,
137
+ timeout: CLI_TIMEOUT_MS,
138
+ maxBuffer: MAX_BUFFER,
139
+ windowsHide: true
140
+ }, (error, stdout, stderr) => {
141
+ if (error) {
142
+ if (error.code === 'ENOENT') {
143
+ reject(missingCliError(cli));
144
+ return;
145
+ }
146
+ if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
147
+ reject(new IntegrationError(`btp CLI "${label}" output exceeded 32 MiB; narrow the request`));
148
+ return;
149
+ }
150
+ if (error.killed) {
151
+ reject(new IntegrationError(`btp CLI "${label}" timed out after ${CLI_TIMEOUT_MS / 1000} s`, { code: 'BTP_CLI_TIMEOUT' }));
152
+ return;
153
+ }
154
+ if (LOGIN_REQUIRED.test(`${stderr ?? ''}\n${stdout ?? ''}`)) {
155
+ reject(new IntegrationError(BTP_LOGIN_REQUIRED_MESSAGE, { code: 'BTP_LOGIN_REQUIRED' }));
156
+ return;
157
+ }
158
+ reject(new IntegrationError(`btp CLI "${label}" failed: ${errorDetail(stderr, stdout, error)}`));
159
+ return;
160
+ }
161
+ resolvePromise(String(stdout ?? ''));
162
+ });
163
+ child?.stdin?.end();
164
+ }));
165
+
166
+ const text = (words, flags) => exec(words, ['--format', 'json', ...words, ...flatten(flags)]);
167
+
168
+ const json = async (words, flags) => {
169
+ const stdout = await text(words, flags);
170
+ const parsed = parseCliJson(stdout);
171
+ if (parsed === undefined) throw new IntegrationError(`btp CLI "${words.join(' ')}" returned output that is not JSON`);
172
+ return parsed;
173
+ };
174
+
175
+ const version = async () => {
176
+ const stdout = await exec(['--version'], ['--version']);
177
+ return stdout.match(/client v(\d+(?:\.\d+)+)/)?.[1] ?? null;
178
+ };
179
+
180
+ return { json, text, version };
181
+ }
182
+
183
+ /** Interactive `btp login --sso` on the caller's terminal (setup wizard only). */
184
+ export function loginBtpCli({ cli, config = null, env = process.env, spawnImpl = spawn } = {}) {
185
+ return new Promise((resolvePromise, reject) => {
186
+ let child;
187
+ try {
188
+ child = spawnImpl(cli, ['login', '--sso'], { env: childEnvironment(env, config), stdio: 'inherit' });
189
+ } catch (error) {
190
+ reject(error?.code === 'ENOENT' ? missingCliError(cli) : error);
191
+ return;
192
+ }
193
+ child.once('error', error => reject(error?.code === 'ENOENT' ? missingCliError(cli) : error));
194
+ child.once('exit', code => {
195
+ if (code === 0) resolvePromise();
196
+ else reject(new IntegrationError(`btp login --sso exited with code ${code}`));
197
+ });
198
+ });
199
+ }
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // sap-ai-btp: stdio MCP server for SAP BTP core platform administration through
3
+ // the user's own btp CLI login (btp login --sso): account hierarchy, entitlements
4
+ // and quotas, Service Manager instances and keys, destinations, trust and Cloud
5
+ // Connectors. The toolkit stores no credentials for it.
6
+ import { realpathSync } from 'node:fs';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import { btpSettingsPath, createBtpCli, resolveBtpCliSettings } from './btp-cli.mjs';
12
+ import { registerBtpTools } from './btp-tools.mjs';
13
+
14
+ const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
15
+
16
+ /** Create an injectable server runtime for tests and the stdio executable. */
17
+ export function createBtpServer({ env = process.env, execFileImpl } = {}) {
18
+ const settings = resolveBtpCliSettings(env);
19
+ const cli = createBtpCli({ cli: settings.cli, config: settings.config, env, ...(execFileImpl ? { execFileImpl } : {}) });
20
+
21
+ const summary = Object.freeze({
22
+ cli: settings.cli,
23
+ cli_source: settings.cliSource,
24
+ config_file: settings.config ?? 'btp CLI default',
25
+ acting_user: 'the user signed in to the btp CLI (btp login --sso)'
26
+ });
27
+
28
+ const verify = async () => {
29
+ let cliVersion;
30
+ try {
31
+ cliVersion = await cli.version() ?? 'unknown';
32
+ } catch (error) {
33
+ cliVersion = `failed: ${error?.message || 'btp --version failed'}`;
34
+ }
35
+ let globalAccount;
36
+ try {
37
+ const account = await cli.json(['get', 'accounts/global-account']);
38
+ globalAccount = `ok (${account?.displayName}, subdomain ${account?.subdomain})`;
39
+ } catch (error) {
40
+ globalAccount = `failed: ${error?.message || 'reading the global account failed'}`;
41
+ }
42
+ return { cli_version: cliVersion, global_account: globalAccount };
43
+ };
44
+
45
+ const server = new McpServer({
46
+ name: 'sap-ai-btp',
47
+ version: packageMetadata.version
48
+ }, { capabilities: { tools: {} } });
49
+ registerBtpTools(server, { cli, summary, verify, env });
50
+
51
+ let closing;
52
+ const close = () => {
53
+ if (!closing) closing = server.close().catch(() => {});
54
+ return closing;
55
+ };
56
+
57
+ return { server, close, verify, connectionSummary: summary };
58
+ }
59
+
60
+ export async function runBtpServer({ env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr } = {}) {
61
+ if (process.argv.slice(2).some(argument => argument === '--help' || argument === '-h')) {
62
+ stdout.write([
63
+ 'Usage: sap-ai-btp',
64
+ '',
65
+ 'Starts a stdio MCP server for SAP BTP core platform administration through your own btp CLI',
66
+ 'login: global account hierarchy, entitlements and quotas, Service Manager instances and service',
67
+ 'keys, destinations, identity and destination trust, and Cloud Connectors. Creating instances and',
68
+ 'service keys requires exact confirmation strings; credentials are always redacted.',
69
+ '',
70
+ 'Sign in with "btp login --sso" (sessions last at most 12 hours); the toolkit stores no credentials.',
71
+ ' BTP_CLI path to the btp executable (else the location saved by sap-ai-dev --setup-btp, else btp on PATH)',
72
+ ' BTP_CLIENTCONFIG btp CLI configuration file (else the saved value, else the btp CLI default)',
73
+ ` ${btpSettingsPath(env)}`,
74
+ ' btp CLI location saved by sap-ai-dev --setup-btp (never mcp.json)',
75
+ '',
76
+ 'HTTP_PROXY, HTTPS_PROXY and NO_PROXY are honored.',
77
+ '',
78
+ ' --check verify the btp CLI and its login session; exit 1 when the check fails',
79
+ ''
80
+ ].join('\n'));
81
+ return;
82
+ }
83
+ let runtime;
84
+ try {
85
+ runtime = createBtpServer({ env });
86
+ } catch (error) {
87
+ stderr.write(`[sap-ai-btp] ${error.message}\n`);
88
+ process.exitCode = 1;
89
+ return;
90
+ }
91
+ if (process.argv.slice(2).includes('--check')) {
92
+ const report = await runtime.verify();
93
+ stdout.write(`${Object.entries(report).map(([name, result]) => `${name}: ${result}`).join('\n')}\n`);
94
+ await runtime.close();
95
+ if (Object.values(report).some(result => String(result).startsWith('failed'))) process.exitCode = 1;
96
+ return;
97
+ }
98
+
99
+ const transport = new StdioServerTransport(stdin, stdout, { maxBufferSize: 1024 * 1024 });
100
+ let closing;
101
+ const shutdown = () => {
102
+ if (!closing) closing = runtime.close().catch(() => {});
103
+ return closing;
104
+ };
105
+ const onStdinEnd = () => { void shutdown(); };
106
+ const onSignal = signal => {
107
+ void shutdown().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
108
+ };
109
+ stdin.once('end', onStdinEnd);
110
+ process.once('SIGINT', onSignal);
111
+ process.once('SIGTERM', onSignal);
112
+ transport.onerror = () => stderr.write('[sap-ai-btp] MCP stdio transport failed.\n');
113
+
114
+ try {
115
+ await runtime.server.connect(transport);
116
+ } catch {
117
+ stderr.write('[sap-ai-btp] MCP server startup failed.\n');
118
+ await shutdown();
119
+ process.exitCode = 1;
120
+ }
121
+ }
122
+
123
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
124
+ await runBtpServer();
125
+ }