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.
- package/.github/agents/sap-btp-infrastructure-admin.agent.md +23 -0
- package/.github/agents/sap-bw-datasphere-migration-specialist.agent.md +22 -0
- package/.github/agents/sap-datasphere-developer.agent.md +22 -0
- package/.github/agents/sap-devops-observability-specialist.agent.md +54 -0
- package/.github/agents/sap-process-automation-developer.agent.md +50 -0
- package/.github/skills/btp-platform-administration/SKILL.md +53 -0
- package/.github/skills/bw-datasphere-migration/SKILL.md +66 -0
- package/.github/skills/cloud-alm-operations/SKILL.md +135 -0
- package/.github/skills/datasphere-administration/SKILL.md +40 -0
- package/.github/skills/datasphere-development/SKILL.md +55 -0
- package/.github/skills/process-automation-development/SKILL.md +113 -0
- package/NOTICE +2 -1
- package/README.md +178 -12
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/inventory.md +60 -0
- package/package.json +8 -2
- package/patches/vsp-single-logon.patch +258 -0
- package/scripts/build-vsp.mjs +1 -1
- package/src/btp-cli.mjs +199 -0
- package/src/btp-server.mjs +125 -0
- package/src/btp-tools.mjs +776 -0
- package/src/cloud-alm-client.mjs +44 -0
- package/src/cloud-alm-config.mjs +92 -0
- package/src/cloud-alm-credentials.mjs +29 -0
- package/src/cloud-alm-data.mjs +129 -0
- package/src/cloud-alm-server.mjs +115 -0
- package/src/cloud-alm-tools.mjs +682 -0
- package/src/datasphere-auth.mjs +279 -0
- package/src/datasphere-cli.mjs +220 -0
- package/src/datasphere-command.mjs +236 -0
- package/src/datasphere-config.mjs +154 -0
- package/src/datasphere-credentials.mjs +37 -0
- package/src/datasphere-csn.mjs +73 -0
- package/src/datasphere-discovery.mjs +151 -0
- package/src/datasphere-server.mjs +249 -0
- package/src/datasphere-tools.mjs +809 -0
- package/src/hana-database.mjs +3 -3
- package/src/hana-inspector.mjs +2 -2
- package/src/harnesses.mjs +31 -1
- package/src/install-user-assets.mjs +1 -1
- package/src/integration-common.mjs +7 -7
- package/src/integration-config.mjs +5 -0
- package/src/integration-credentials.mjs +25 -16
- package/src/integration-http.mjs +24 -7
- package/src/launcher.mjs +56 -4
- package/src/mcp-config.mjs +37 -1
- package/src/process-automation-automation-tools.mjs +125 -0
- package/src/process-automation-client.mjs +36 -0
- package/src/process-automation-common.mjs +69 -0
- package/src/process-automation-config.mjs +142 -0
- package/src/process-automation-credentials.mjs +37 -0
- package/src/process-automation-inbox-tools.mjs +246 -0
- package/src/process-automation-server.mjs +139 -0
- package/src/process-automation-tools.mjs +727 -0
- package/src/process-automation-visibility-tools.mjs +182 -0
- package/src/setup.mjs +623 -11
- 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
|
+
+}
|
package/scripts/build-vsp.mjs
CHANGED
|
@@ -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 });
|
package/src/btp-cli.mjs
ADDED
|
@@ -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
|
+
}
|