sap-ai-dev-toolkit 0.6.5 → 0.6.6

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/README.md CHANGED
@@ -544,7 +544,7 @@ For a transport report, include exactly one `GetTransport` check whose `transpor
544
544
 
545
545
  For an ABAP Cloud assessment, get object URIs from `SearchObject` and pass them to `PlanABAPCloudMigration`. It reports release evidence; it does not guess replacement APIs. Save the JSON from `GenerateRAPRegressionSuite` and pass it to `RunRAPRegressionSuite` after relevant service changes.
546
546
 
547
- `sap-ai-dev --doctor` probes each discovered destination, checks that each managed MCP entry still matches its selected BAS destination, starts VSP, lists MCP tools, and preflights the runtime relay's CSRF session (token fetched from the GET-capable ADT discovery endpoint, the same token POST-only services such as data preview rely on) before calling `GetSystemInfo`. It repairs only tagged toolkit-owned BAS entries that can be verified against complete destination discovery; it does not add destinations, remove stale or third-party entries, or overwrite malformed configuration. `--doctor --json` reports redacted session diagnostics (HTTP status, cookie count, retry/fallback counters; never token or cookie values) and exits unsuccessfully when a required check fails.
547
+ `sap-ai-dev --doctor` probes each discovered destination, checks that each managed MCP entry still matches its selected BAS destination, starts VSP, lists MCP tools, and calls `GetSystemInfo` directly when that tool is exposed. It repairs only tagged toolkit-owned BAS entries that can be verified against complete destination discovery; it does not add destinations, remove stale or third-party entries, or overwrite malformed configuration. `--doctor --json` reports redacted diagnostics and exits unsuccessfully when a required check fails.
548
548
 
549
549
  `tools/list` confirms what this MCP server exposes; it does not prove that VS Code attached the server to the active chat. Start the selected destination in **MCP: List Servers**, enable it in the Chat tools picker, and reload/reselect the agent if the host binding is stale. This add-on cannot inspect or repair another MCP server's registration or a host-wide tool budget.
550
550
 
@@ -738,9 +738,7 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
738
738
  | `SAP_AI_DEV_TOOLKIT_DESTINATION` | Comma-separated destination allowlist for normal runtime discovery. Setup clears this temporarily so it can display all eligible systems. |
739
739
  | `SAP_AI_DEV_TOOLKIT_MODE` | VSP child mode (`expert` by default; `focused` omits `ActivateMultiple`, `GetUserTransports`, and `GetTransportInfo`). The proxy exposes its curated tool subset and local workflow tools, including `LintABAP`. |
740
740
  | `SAP_ALLOW_TRANSPORTABLE_EDITS` | Generated MCP entries set this to `true` to permit source edits in transportable packages; VSP safety checks and SAP authorizations still apply. |
741
- | `SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY=true` | Disable the built-in BAS destination relay. By default the add-on self-heals `.dest` destinations through a local relay that keeps all access destination-based while handling ADT CSRF fetch/retry behavior before VSP calls SAP. |
742
- | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
743
- | `SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES` | Bounded retries after explicit CSRF-session rejection per unsafe request (default 3, maximum 10). Invalid values use the default. |
741
+ | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for BAS discovery and probe traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
744
742
  | `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` | Per-request timeout for calls forwarded to a VSP child (default 600000 = 10 minutes; `0` disables). A stalled request fails with a timeout error; the child is left running. |
745
743
  | `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
746
744
  | `SAP_AI_DEV_TOOLKIT_MCP_CONFIG` | Branded compatibility alias for the MCP configuration path. |
@@ -757,18 +755,13 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
757
755
 
758
756
  #### Self-healing modes
759
757
 
760
- The relay between the VSP child and each BAS destination recovers from the failure modes that break ADT writes over `.dest` proxies:
761
-
762
- 1. **CSRF session pairing** — a scoped cookie jar retains session cookies received during safe reads and token fetches, applies updates and expirations, and replays the matching token/cookie pair on POST/PUT/PATCH/DELETE. Tokens are session-scoped and always fetched from the GET-capable `/sap/bc/adt/discovery` endpoint, so unsafe requests to POST-only services (ADT data preview) are covered too. If no token can be obtained at all, the relay fails closed and does not send the unsafe request. A token that arrives without session cookies is still used — BAS proxy routes strip `Set-Cookie`, yet the backend accepts the token on the proxy-established Basic-auth session — and a CSRF rejection on such a cookie-less route is surfaced without pointless retries.
763
- 2. **Session refresh** — when SAP explicitly rejects a CSRF token, the cached session is dropped, a fresh token+cookie pair is fetched, and the request is retried within the configured bound. Generic authorization 401/403 responses are returned without being mislabeled or retried as CSRF failures.
764
- 3. **Proxy tunnel fallback** — when the BAS proxy refuses absolute-form requests (502/504 or transport errors), the relay switches to a CONNECT tunnel through the same proxy and keeps going.
765
- 4. **Child crash recovery** — a crashed VSP child is restarted transparently, re-initialized, tools re-registered, and the interrupted `tools/call` retried once before any error reaches the client.
766
- 5. **Configured destination authentication** — BAS destination children use the selected BAS destination as their only SAP identity. The toolkit clears VSP local authentication environment values so a project `.env` cannot override or conflict with destination authentication. Setup does not prompt for or store SAP usernames/passwords, and the relay does not connect directly to a backend host as a credential fallback. Cloud Foundry destinations use only credentials returned by their configured Destination service record.
758
+ VSP children connect to each configured destination URL directly. BAS destination authentication remains selected through VSP's `--proxy-auth` path; Cloud Foundry on-premise destinations continue through the separate Connectivity proxy.
759
+ Child crash recovery restarts a failed VSP child, re-initializes it, re-registers tools, and retries the interrupted `tools/call` once before surfacing an error. State-changing tools are not retried.
767
760
  Existing installs that still set the previous `BAS_VSP_*` environment variables remain supported. The setup wizard writes new MCP entries with the `SAP_AI_DEV_TOOLKIT_*` names.
768
761
 
769
762
  #### Server logging
770
763
 
771
- Every observable event — startup banner (version, PID, VSP binary), destination discovery, relay activation, VSP session initialization, each JSON-RPC request with its id, every tool call with duration and outcome, self-healing restarts, and errors — is logged twice: to **stderr** with ISO timestamps (visible when the server runs in a terminal or in hosts that surface stderr), and to the host's **MCP server output channel** as standard `notifications/message` log notifications (VS Code/BAS and Claude Code render these under the server's output). Log notifications start at `info`; send `logging/setLevel` with `debug` (or higher) from the client to adjust what reaches the channel — stderr always receives everything. Credentials, cookies, and tokens are redacted in both streams.
764
+ Every observable event — startup banner (version, PID, VSP binary), destination discovery, VSP session initialization, each JSON-RPC request with its id, every tool call with duration and outcome, self-healing restarts, and errors — is logged twice: to **stderr** with ISO timestamps (visible when the server runs in a terminal or in hosts that surface stderr), and to the host's **MCP server output channel** as standard `notifications/message` log notifications (VS Code/BAS and Claude Code render these under the server's output). Log notifications start at `info`; send `logging/setLevel` with `debug` (or higher) from the client to adjust what reaches the channel — stderr always receives everything. Credentials, cookies, and tokens are redacted.
772
765
 
773
766
  <a id="troubleshooting"></a>
774
767
 
@@ -785,6 +778,8 @@ Next, check that the backend answers at `/sap/bc/adt`.
785
778
 
786
779
  Runtime diagnostics take the stderr lane. In the MCP server's Output view, check per-destination ADT probe status, VSP child stderr, and failed tool-call details; stdout is reserved for MCP protocol messages.
787
780
 
781
+ If `GetSystemInfo` or `RunQuery` reports a CSRF-token fetch failure, VSP tries `/sap/bc/adt/core/discovery` first and falls back to `/sap/bc/adt/discovery` when the core endpoint returns HTTP 404 or 5xx without a token. If both endpoints fail, the error includes their HTTP statuses; check BAS destination routing and the SAP ADT service. `GetInstalledComponents` is a separate GET request and a failure there is not evidence of a CSRF problem.
782
+
788
783
  If automatic Go or VSP provisioning fails, check network access and the package's supported platform. You can install Go manually or set `GO_BINARY` as an explicit fallback. A trusted prebuilt VSP can be supplied with `SAP_AI_DEV_TOOLKIT_BINARY`.
789
784
 
790
785
  ## 🧑‍💻 Development
@@ -1,5 +1,5 @@
1
- 78c0f6b7adf151a4c83ee3a757752aa409225fc75af64dfe728aed68dc5cf598 vsp-linux-x64
2
- 4ceaf3ef3519431aa5ebe7d7c14569cf7c7035956dbfc9342c8fa9b6343a5380 vsp-linux-arm64
3
- f8d075f1ef955f8bb4be5b1d9ec3ffc86e84349b8a141265cf39e74c9fd83246 vsp-darwin-x64
4
- 25e19e0d7317fe5adcb3c670b5fcf1da1caafa3c1bf8a6783c4ff00b312b5194 vsp-darwin-arm64
5
- c67c921ae884abeacaa39782c5524f58ec58bdbccc0d7097ee8a2499c04331ac vsp-win32-x64.exe
1
+ 8ab555658815db7de670b956dafe0f12d6a249e4f1e1cbbb058deb658a89a37d vsp-linux-x64
2
+ 7011b716e295ce2cecc40b9a56e57d9a1ec24c2b9d6aa25ab46e5cde0ec23998 vsp-linux-arm64
3
+ 5c240a6b53e15ebaae817612aed1c5b95624a99adfc38e6abb9afa377de30d1a vsp-darwin-x64
4
+ 28032c4d331253808ec9318e6db570420c6c9308935964de665e34374465d89f vsp-darwin-arm64
5
+ 5bb45857489bbe5d57587d9abda0b768d3a1b352d89a2a6bf13b23af8d2fd45e vsp-win32-x64.exe
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
@@ -12,7 +12,6 @@
12
12
  "@modelcontextprotocol/sdk": "1.30.1",
13
13
  "@sap/hana-client": "2.30.27",
14
14
  "terminal-kit": "^3.1.4",
15
- "tough-cookie": "^5.1.2",
16
15
  "undici": "^6.21.3",
17
16
  "zod": "4.6.5"
18
17
  },
@@ -308,3 +308,128 @@ diff --git a/internal/mcp/tools_register.go b/internal/mcp/tools_register.go
308
308
  + }),
309
309
  ),
310
310
  ), s.handleActivateMultiple)
311
+ }
312
+ diff --git a/pkg/adt/http.go b/pkg/adt/http.go
313
+ --- a/pkg/adt/http.go
314
+ +++ b/pkg/adt/http.go
315
+ @@ -499,6 +499,7 @@ func (t *Transport) fetchCSRFTokenFor(ctx context.Context, stateful bool) error
316
+ // allowReauth exists to break a cycle: re-authenticating ends with a token
317
+ // fetch of its own, and that fetch must not start another re-authentication.
318
+ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bool, stateful bool) error {
319
+ + var alternateFailure string
320
+ token, status, redirected, err := t.probeCSRFToken(ctx, http.MethodHead, stateful)
321
+ if err != nil {
322
+ return err
323
+ @@ -513,6 +514,29 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
324
+ if err != nil {
325
+ return err
326
+ }
327
+ + // Some ADT installations return 500 from /core/discovery even though
328
+ + // the standard /discovery endpoint can issue a token. Keep the
329
+ + // existing fast path, but do not make every POST depend on the
330
+ + // failing endpoint.
331
+ + if !isCSRFToken(token) && (getStatus >= 500 || getStatus == http.StatusNotFound) {
332
+ + coreStatus := getStatus
333
+ + token, status, redirected, err = t.probeCSRFTokenAt(ctx, http.MethodHead, stateful, "/sap/bc/adt/discovery")
334
+ + if err != nil {
335
+ + return err
336
+ + }
337
+ + if !isCSRFToken(token) {
338
+ + if status == http.StatusUnauthorized && !t.canReauth() {
339
+ + return fmt.Errorf("authentication failed (401): check username/password")
340
+ + }
341
+ + token, getStatus, getRedirected, err = t.probeCSRFTokenAt(ctx, http.MethodGet, stateful, "/sap/bc/adt/discovery")
342
+ + if err != nil {
343
+ + return err
344
+ + }
345
+ + if !isCSRFToken(token) {
346
+ + alternateFailure = fmt.Sprintf("no CSRF token from /sap/bc/adt/core/discovery (GET %d) or /sap/bc/adt/discovery (HEAD %d, GET %d)", coreStatus, status, getStatus)
347
+ + }
348
+ + }
349
+ + }
350
+ if !isCSRFToken(token) {
351
+ // An expired SSO session rarely announces itself as a 401. ICF sends
352
+ // the request on to the identity provider, the redirect chain is
353
+ @@ -537,6 +561,9 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
354
+ }
355
+ return t.fetchCSRFTokenWithReauth(ctx, false, stateful)
356
+ }
357
+ + if alternateFailure != "" {
358
+ + return errors.New(alternateFailure)
359
+ + }
360
+ switch getStatus {
361
+ case http.StatusUnauthorized:
362
+ return fmt.Errorf("authentication failed (401): check username/password")
363
+ @@ -556,7 +583,11 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
364
+ // returns the token (empty when the server did not supply one), the status, and
365
+ // whether the answer came from somewhere other than the SAP host.
366
+ func (t *Transport) probeCSRFToken(ctx context.Context, method string, stateful bool) (token string, status int, redirected bool, err error) {
367
+ - reqURL, err := t.buildURL("/sap/bc/adt/core/discovery", nil)
368
+ + return t.probeCSRFTokenAt(ctx, method, stateful, "/sap/bc/adt/core/discovery")
369
+ +}
370
+ +
371
+ +func (t *Transport) probeCSRFTokenAt(ctx context.Context, method string, stateful bool, path string) (token string, status int, redirected bool, err error) {
372
+ + reqURL, err := t.buildURL(path, nil)
373
+ if err != nil {
374
+ return "", 0, false, fmt.Errorf("building URL: %w", err)
375
+ }
376
+ diff --git a/pkg/adt/http_csrf_discovery_test.go b/pkg/adt/http_csrf_discovery_test.go
377
+ new file mode 100644
378
+ --- /dev/null
379
+ +++ b/pkg/adt/http_csrf_discovery_test.go
380
+ @@ -0,0 +1,55 @@
381
+ +package adt
382
+ +
383
+ +import (
384
+ + "context"
385
+ + "net/http"
386
+ + "net/http/httptest"
387
+ + "strings"
388
+ + "testing"
389
+ +)
390
+ +
391
+ +func TestCSRFAlternateDiscoveryOnCoreFailure(t *testing.T) {
392
+ + var requests []string
393
+ + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
394
+ + requests = append(requests, r.Method+" "+r.URL.Path)
395
+ + switch r.URL.Path {
396
+ + case "/sap/bc/adt/core/discovery":
397
+ + w.WriteHeader(http.StatusInternalServerError)
398
+ + case "/sap/bc/adt/discovery":
399
+ + w.Header().Set("X-CSRF-Token", "valid-token")
400
+ + case "/sap/bc/adt/datapreview/freestyle":
401
+ + if r.Header.Get("X-CSRF-Token") != "valid-token" {
402
+ + t.Errorf("POST missing fetched token")
403
+ + w.WriteHeader(http.StatusForbidden)
404
+ + }
405
+ + default:
406
+ + t.Errorf("unexpected request %s", r.URL.Path)
407
+ + }
408
+ + }))
409
+ + defer srv.Close()
410
+ + tr := newCSRFTestTransport(t, srv)
411
+ + _, err := tr.Request(context.Background(), "/sap/bc/adt/datapreview/freestyle", &RequestOptions{Method: http.MethodPost, Body: []byte("SELECT * FROM T000")})
412
+ + if err != nil {
413
+ + t.Fatalf("read-only POST failed: %v", err)
414
+ + }
415
+ + want := []string{"HEAD /sap/bc/adt/core/discovery", "GET /sap/bc/adt/core/discovery", "HEAD /sap/bc/adt/discovery", "POST /sap/bc/adt/datapreview/freestyle"}
416
+ + if strings.Join(requests, "|") != strings.Join(want, "|") {
417
+ + t.Errorf("requests = %v, want %v", requests, want)
418
+ + }
419
+ +}
420
+ +
421
+ +func TestCSRFAlternateDiscoveryFailureIncludesStatuses(t *testing.T) {
422
+ + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
423
+ + if r.URL.Path == "/sap/bc/adt/core/discovery" {
424
+ + w.WriteHeader(http.StatusInternalServerError)
425
+ + } else {
426
+ + w.WriteHeader(http.StatusForbidden)
427
+ + }
428
+ + }))
429
+ + defer srv.Close()
430
+ + tr := newCSRFTestTransport(t, srv)
431
+ + err := tr.fetchCSRFToken(context.Background())
432
+ + if err == nil || !strings.Contains(err.Error(), "500") || !strings.Contains(err.Error(), "403") {
433
+ + t.Fatalf("expected both endpoint failures, got %v", err)
434
+ + }
435
+ +}
package/src/launcher.mjs CHANGED
@@ -89,16 +89,6 @@ async function runDoctor(destinations) {
89
89
  const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
90
90
  if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
91
91
  checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
92
- const relayedDestination = proxy.children[0]?.destination;
93
- if (relayedDestination?.relay) {
94
- checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); runtime session preflight pending`));
95
- } else if (destination.source === 'cloud-foundry') {
96
- checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Cloud Foundry destinations connect through the connectivity proxy directly.'));
97
- } else if (String(runtimeEnv.SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY || '').toLowerCase() === 'true') {
98
- checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Disabled through SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY.'));
99
- } else {
100
- checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
101
- }
102
92
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
103
93
  if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
104
94
  const tools = listed.result?.tools || [];
@@ -114,32 +104,9 @@ async function runDoctor(destinations) {
114
104
  if (!systemInfo) {
115
105
  checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
116
106
  } else {
117
- let sessionReady = true;
118
- if (relayedDestination?.relay?.probeCsrfSession) {
119
- let session;
120
- let preflightError;
121
- try {
122
- session = await relayedDestination.relay.probeCsrfSession();
123
- } catch (error) {
124
- preflightError = redactText(error.message || error).slice(0, 250);
125
- }
126
- sessionReady = session?.sessionUsable === true;
127
- const status = sessionReady ? 'passed' : 'failed';
128
- const stats = relayedDestination.relay.stats || {};
129
- const detail = preflightError
130
- ? `preflight request failed: ${preflightError}`
131
- : `HTTP ${session?.httpStatus || 0}; token=${session?.tokenReceived ? 'received' : 'missing'}; cookies=${session?.cookieCount || 0}; usableSession=${sessionReady}; csrfFetches=${stats.csrfFetches || 0}; csrfRetries=${stats.csrfRetries || 0}; sessionFailures=${stats.csrfSessionFailures || 0}; tunnelFallbacks=${stats.tunnelFallbacks || 0}`;
132
- checks.push(doctorRow(destination.name, 'BAS relay CSRF preflight', status, detail));
133
- if (!sessionReady) {
134
- const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session. Confirm the configured destination authentication and BAS proxy route.';
135
- checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', preflightError ? `${remediation} Relay error: ${preflightError}` : remediation));
136
- }
137
- }
138
- if (sessionReady) {
139
- const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
140
- const failed = inspected?.error || inspected?.result?.isError;
141
- checks.push(doctorRow(destination.name, 'SAP system check', failed ? 'failed' : 'passed', failed ? redactText(inspected?.error?.message || inspected?.result?.content?.[0]?.text || 'GetSystemInfo failed') : 'GetSystemInfo returned successfully'));
142
- }
107
+ const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
108
+ const failed = inspected?.error || inspected?.result?.isError;
109
+ checks.push(doctorRow(destination.name, 'SAP system check', failed ? 'failed' : 'passed', failed ? redactText(inspected?.error?.message || inspected?.result?.content?.[0]?.text || 'GetSystemInfo failed') : 'GetSystemInfo returned successfully'));
143
110
  }
144
111
  } catch (error) {
145
112
  const detail = redactText(error.message || error).slice(0, 300);
package/src/mcp-proxy.mjs CHANGED
@@ -4,7 +4,6 @@ import { sanitizeChildEnv, slugifyDestination } from './bas-discovery.mjs';
4
4
  import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
5
5
  import { createEngineeringTools } from './engineering-tools.mjs';
6
6
  import { brandedEnvValue } from './branding.mjs';
7
- import { createBasDestinationRelay } from './bas-destination-relay.mjs';
8
7
  import { diagnosticText, redactText } from './redact.mjs';
9
8
 
10
9
  const JSONRPC = '2.0';
@@ -167,12 +166,6 @@ function closeDestinationRoute(destination) {
167
166
  return closedRoutes.get(destination);
168
167
  }
169
168
 
170
- function useBasDestinationRelay(destination, env = process.env) {
171
- if (String(brandedEnvValue(env, 'DISABLE_BAS_RELAY') || '').toLowerCase() === 'true') return false;
172
- if (destination.source === 'cloud-foundry') return false;
173
- if (!destination.authentication) return false;
174
- return Boolean(destination.url && String(destination.url).includes('.dest'));
175
- }
176
169
 
177
170
  function useProxyAuthentication(destination) {
178
171
  return destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation';
@@ -190,17 +183,6 @@ function childEnvironment(destination, env) {
190
183
  'SAP_SAML_AUTH', 'SAP_SAML_USER', 'SAP_SAML_PASSWORD', 'SAP_CREDENTIAL_CMD'
191
184
  ]) childEnv[key] = '';
192
185
  }
193
- // The VSP child must reach the local BAS relay directly, never through the
194
- // BAS .dest proxy. Without a loopback exception every child request would
195
- // be double-proxied and the relay's session pairing could never apply.
196
- if (destination.relay) {
197
- const loopback = '127.0.0.1,localhost';
198
- for (const key of ['NO_PROXY', 'no_proxy']) {
199
- const entries = String(childEnv[key] || '').split(',').map(value => value.trim()).filter(Boolean);
200
- for (const host of loopback.split(',')) if (!entries.some(entry => entry.toLowerCase() === host)) entries.push(host);
201
- childEnv[key] = entries.join(',');
202
- }
203
- }
204
186
  return childEnv;
205
187
  }
206
188
 
@@ -245,14 +227,12 @@ class Child {
245
227
  this.process.on('error', error => {
246
228
  if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
247
229
  this.fail(error);
248
- // Relay routes survive child crashes so self-healing restarts can reuse
249
- // them; they are closed by the proxy shutdown path instead.
250
- if (!destination.relay) void closeDestinationRoute(destination);
230
+ void closeDestinationRoute(destination);
251
231
  });
252
232
  this.process.on('exit', (code, signal) => {
253
233
  if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child exited (${code ?? signal})`);
254
234
  this.fail(new Error(`child exited (${code ?? signal})`));
255
- if (!destination.relay) void closeDestinationRoute(destination);
235
+ void closeDestinationRoute(destination);
256
236
  });
257
237
  }
258
238
 
@@ -442,34 +422,10 @@ export class MCPProxy {
442
422
  if (this.started) return this;
443
423
  this.started = true;
444
424
  this.starting = (async () => {
445
- // Destinations start concurrently (relay bind + child spawn); children
446
- // are assembled in destination order, which keeps serverInfo's
447
- // children[0] fallback and merged slug numbering deterministic.
448
- const started = await Promise.all(this.destinations.map(originalDestination => (async () => {
449
- let destination = originalDestination;
450
- let relay;
425
+ // Destinations start concurrently and are assembled in destination order,
426
+ // keeping serverInfo's children[0] fallback and merged slug numbering deterministic.
427
+ const started = await Promise.all(this.destinations.map(destination => (async () => {
451
428
  try {
452
- if (useBasDestinationRelay(originalDestination, this.env)) {
453
- relay = createBasDestinationRelay(originalDestination, { env: this.env, log: message => this.eventSink(message) });
454
- const originalClose = originalDestination.close;
455
- const relayUrl = await relay.ready;
456
- destination = {
457
- ...originalDestination,
458
- url: relayUrl,
459
- relay: {
460
- probeCsrfSession: relay.probeCsrfSession,
461
- stats: relay.stats
462
- },
463
- async close() {
464
- try {
465
- await originalClose?.();
466
- } finally {
467
- await relay.close();
468
- }
469
- }
470
- };
471
- this.eventSink(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
472
- }
473
429
  this.eventSink(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
474
430
  return {
475
431
  destination,
@@ -477,8 +433,6 @@ export class MCPProxy {
477
433
  };
478
434
  } catch (error) {
479
435
  this.eventSink(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`, 'error');
480
- // A relay whose ready promise rejected still owns dispatchers.
481
- if (relay && destination === originalDestination) await relay.close().catch(() => {});
482
436
  void closeDestinationRoute(destination);
483
437
  return null;
484
438
  }
@@ -1,377 +0,0 @@
1
- import http from 'node:http';
2
- import { fetch as undiciFetch, ProxyAgent } from 'undici';
3
- import { CookieJar } from 'tough-cookie';
4
- import { brandedEnvValue } from './branding.mjs';
5
- import { redactText } from './redact.mjs';
6
-
7
- const DEFAULT_PROXY = 'http://127.0.0.1:8887';
8
- const MAX_BODY_BYTES = 64 * 1024 * 1024;
9
- const HOP_BY_HOP = new Set([
10
- 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
11
- 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host'
12
- ]);
13
- const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
14
- // SAP ties CSRF tokens to the session that fetched them. The BAS .dest proxy
15
- // authenticates each hop, so the relay pairs every token with the exact
16
- // Set-Cookie state SAP returned alongside it and replays both on the unsafe
17
- // request. Without this pairing SAP sees a token from a foreign session and
18
- // answers "CSRF token validation failed" with HTTP 403.
19
- // Tokens are session-scoped, not path-scoped, but SAP only issues them from
20
- // GET-capable endpoints. POST-only services (ADT data preview among them)
21
- // answer a same-path token fetch with 4xx, so the relay always fetches from
22
- // the GET-friendly discovery endpoint and accepts that session's token for
23
- // unsafe requests to any path.
24
- const CSRF_FETCH_PATH = '/sap/bc/adt/discovery';
25
- const TOKEN_TTL_MS = 15 * 60 * 1000;
26
- const DEFAULT_MAX_TOKEN_FAILURES = 3;
27
- const MAX_CONFIGURED_TOKEN_FAILURES = 10;
28
- const MAX_TUNNEL_FALLBACKS = 2;
29
- // Carrying a POST's content-length on the token-fetch GET would make the
30
- // proxy wait for a body that never arrives, so the relay drops body headers.
31
- const BODY_HEADERS = new Set(['content-length', 'content-type']);
32
- // The forwarded request's content-length must describe the body undici is
33
- // about to send. Forwarding the inbound value alongside a re-buffered body
34
- // makes undici reject the request (UND_ERR_INVALID_ARG), so it is always
35
- // recomputed from the actual payload.
36
- const LENGTH_HEADERS = new Set(['content-length', 'transfer-encoding']);
37
- // undici transparently decompresses gzip/deflate/br responses but keeps the
38
- // content-encoding header, so forwarding the client's accept-encoding would
39
- // hand VSP a "gzip" body that is actually plain bytes. Dropping the header
40
- // lets undici request identity encoding and keeps bodies consistent.
41
- const ENCODING_HEADERS = new Set(['accept-encoding', 'content-encoding']);
42
-
43
- function headersFrom(requestHeaders, extra = {}) {
44
- const headers = {};
45
- for (const [name, value] of Object.entries(requestHeaders || {})) {
46
- const lower = name.toLowerCase();
47
- if (HOP_BY_HOP.has(lower) || LENGTH_HEADERS.has(lower) || ENCODING_HEADERS.has(lower)) continue;
48
- if (value !== undefined) headers[name] = Array.isArray(value) ? value.join(', ') : String(value);
49
- }
50
- return { ...headers, ...extra };
51
- }
52
-
53
- function fetchHeaders(requestHeaders, extra = {}) {
54
- const headers = {};
55
- for (const [name, value] of Object.entries(requestHeaders || {})) {
56
- const lower = name.toLowerCase();
57
- if (HOP_BY_HOP.has(lower) || BODY_HEADERS.has(lower) || ENCODING_HEADERS.has(lower)) continue;
58
- if (value !== undefined) headers[name] = Array.isArray(value) ? value.join(', ') : String(value);
59
- }
60
- return { ...headers, ...extra };
61
- }
62
-
63
- function tunnelFailure(status) {
64
- // 502/504 from the proxy itself means the absolute-form .dest request is
65
- // being refused; worth retrying through a CONNECT tunnel.
66
- return status === 502 || status === 504;
67
- }
68
-
69
- function csrfFailure(status, headers, body) {
70
- return status === 403 && (
71
- String(headers.get?.('x-csrf-token') || '').toLowerCase() === 'required' ||
72
- String(body || '').toLowerCase().includes('csrf token validation failed')
73
- );
74
- }
75
-
76
- function maxTokenFailures(env, log, destinationName) {
77
- const configured = brandedEnvValue(env, 'MAX_CSRF_RETRIES');
78
- if (configured === undefined || configured === '') return DEFAULT_MAX_TOKEN_FAILURES;
79
- const parsed = Number(configured);
80
- if (!Number.isSafeInteger(parsed) || parsed < 0) {
81
- log(`[${destinationName}] invalid CSRF retry setting; using the default of ${DEFAULT_MAX_TOKEN_FAILURES}`);
82
- return DEFAULT_MAX_TOKEN_FAILURES;
83
- }
84
- return Math.min(parsed, MAX_CONFIGURED_TOKEN_FAILURES);
85
- }
86
-
87
- function copyCookieJar(jar) {
88
- return CookieJar.deserializeSync(jar.serializeSync());
89
- }
90
-
91
- async function cookieHeader(jar, target) {
92
- return await jar.getCookieString(target.href) || undefined;
93
- }
94
-
95
- async function seedRequestCookies(jar, requestCookie, target) {
96
- for (const pair of String(requestCookie || '').split(';')) {
97
- const separator = pair.indexOf('=');
98
- if (separator <= 0) continue;
99
- const name = pair.slice(0, separator).trim();
100
- const value = pair.slice(separator + 1).trim();
101
- if (!name) continue;
102
- try {
103
- await jar.setCookie(`${name}=${value}; Path=/`, target.href, { ignoreError: true });
104
- } catch {
105
- // Invalid caller-provided cookie fragments are ignored; SAP response
106
- // cookies remain authoritative for the destination session.
107
- }
108
- }
109
- }
110
-
111
- async function recordCookies(jar, headers, target, log, destinationName) {
112
- const values = headers.getSetCookie?.() || (headers.get('set-cookie') ? [headers.get('set-cookie')] : []);
113
- for (const value of values) {
114
- try {
115
- await jar.setCookie(String(value), target.href, { ignoreError: true });
116
- } catch {
117
- log(`[${destinationName}] BAS relay ignored an invalid Set-Cookie header`);
118
- }
119
- }
120
- }
121
-
122
- async function readRequestBody(req) {
123
- const chunks = [];
124
- let size = 0;
125
- for await (const chunk of req) {
126
- size += chunk.length;
127
- if (size > MAX_BODY_BYTES) throw new Error('request body is too large for BAS destination relay');
128
- chunks.push(chunk);
129
- }
130
- return Buffer.concat(chunks);
131
- }
132
-
133
- export function createBasDestinationRelay(destination, { env = process.env, fetchImpl = undiciFetch, log = () => {} } = {}) {
134
- if (!destination?.url) throw new Error('Destination relay requires a BAS destination URL');
135
- const base = new URL(destination.url);
136
- const cookieBase = destination.backendUrl
137
- ? new URL(String(destination.backendUrl))
138
- : (() => {
139
- const target = new URL(base);
140
- // BAS .dest URLs are local HTTP routing aliases for SAP backends that
141
- // are commonly HTTPS. Use a secure cookie context when BAS metadata
142
- // omits the backend URL so Secure session cookies are not discarded.
143
- if (target.protocol === 'http:' && /\.dest$/i.test(target.hostname)) target.protocol = 'https:';
144
- return target;
145
- })();
146
- // BAS destinations must keep their configured .dest route and authentication.
147
- // Proxy resolution: an explicitly configured proxy wins (an explicit empty
148
- // value means "go direct"). Without configuration, BAS virtual .dest hosts
149
- // imply the default BAS proxy; any other host (tests, direct URLs) is
150
- // reached directly so the relay never routes loopback traffic through a
151
- // foreign proxy.
152
- const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
153
- const explicitProxy = brandedProxy !== undefined ? brandedProxy : (env.HTTP_PROXY ?? env.http_proxy);
154
- const proxyUrl = explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : '');
155
- const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
156
- // Self-healing mode 2: if the BAS proxy refuses absolute-form proxied
157
- // requests (its own 502/504), retry through a CONNECT tunnel so the relay
158
- // keeps working through the same egress.
159
- const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
160
- let tunneled = false;
161
- // Observable self-healing counters, surfaced by --doctor and tests.
162
- const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct: false, proxyTunnel: false };
163
- const maxFailures = maxTokenFailures(env, log, destination.name);
164
- // The shared jar captures SAP cookies from safe reads as well as CSRF fetches.
165
- // Each cached token gets its own snapshot so later responses cannot silently
166
- // pair an old token with a different session.
167
- const sharedCookieJar = new CookieJar();
168
- const tokenCache = new Map();
169
- const tokenFetches = new Map();
170
-
171
- function resolveCookieTarget(target) {
172
- return new URL(`${target.pathname}${target.search}`, cookieBase);
173
- }
174
-
175
- async function send(target, method, headers, body) {
176
- const useTunnel = tunneled && tunnelDispatcher;
177
- return fetchImpl(target, {
178
- method,
179
- headers,
180
- body: body?.length ? body : undefined,
181
- ...(useTunnel ? { dispatcher: tunnelDispatcher } : dispatcher ? { dispatcher } : {})
182
- });
183
- }
184
-
185
- // Try absolute-form first; on transport errors or proxy-level failures,
186
- // flip to tunneled mode once and retry (sticky for later requests).
187
- async function sendWithFallback(target, method, headers, body) {
188
- if (tunneled) return send(target, method, headers, body);
189
- try {
190
- const response = await send(target, method, headers, body);
191
- if (!tunnelFailure(response.status)) return response;
192
- log(`[${destination.name}] BAS relay proxy returned HTTP ${response.status} for ${target.pathname}; switching to proxy tunnel`);
193
- } catch (error) {
194
- log(`[${destination.name}] BAS relay absolute-form request failed for ${target.pathname} (${error.message}); switching to proxy tunnel`);
195
- }
196
- tunneled = true;
197
- stats.tunnelFallbacks += 1;
198
- return send(target, method, headers, body);
199
- }
200
-
201
- async function fetchTokenFresh(requestHeaders) {
202
- const key = CSRF_FETCH_PATH;
203
- stats.csrfFetches += 1;
204
- const target = new URL(CSRF_FETCH_PATH, base);
205
- const cookieTarget = resolveCookieTarget(target);
206
- const jar = copyCookieJar(sharedCookieJar);
207
- await seedRequestCookies(jar, requestHeaders.cookie, cookieTarget);
208
- const outgoingCookie = await cookieHeader(jar, cookieTarget);
209
- const response = await sendWithFallback(target, 'GET', fetchHeaders(requestHeaders, {
210
- 'x-csrf-token': 'Fetch',
211
- accept: requestHeaders.accept || 'application/xml,*/*',
212
- ...(outgoingCookie ? { cookie: outgoingCookie } : {})
213
- }));
214
- await response.arrayBuffer().catch(() => undefined);
215
- const token = response.headers.get('x-csrf-token');
216
- await recordCookies(jar, response.headers, cookieTarget, log, destination.name);
217
- await recordCookies(sharedCookieJar, response.headers, cookieTarget, log, destination.name);
218
- const cookies = await jar.getCookies(cookieTarget.href);
219
- const session = {
220
- token: response.ok && token && token.toLowerCase() !== 'required' ? token : null,
221
- jar,
222
- fetchedAt: Date.now(),
223
- httpStatus: response.status,
224
- cookieCount: cookies.length
225
- };
226
- if (session.token) tokenCache.set(key, session);
227
- return session;
228
- }
229
-
230
- async function fetchToken(requestHeaders = {}) {
231
- const key = CSRF_FETCH_PATH;
232
- const cached = tokenCache.get(key);
233
- if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
234
- const pending = tokenFetches.get(key);
235
- if (pending) return pending;
236
- const fetching = fetchTokenFresh(requestHeaders);
237
- tokenFetches.set(key, fetching);
238
- try {
239
- return await fetching;
240
- } finally {
241
- if (tokenFetches.get(key) === fetching) tokenFetches.delete(key);
242
- }
243
- }
244
-
245
- async function probeCsrfSession() {
246
- const session = await fetchToken({ accept: 'application/xml,text/xml,*/*' });
247
- return {
248
- httpStatus: session?.httpStatus || 0,
249
- tokenReceived: Boolean(session?.token),
250
- cookieCount: session?.cookieCount || 0,
251
- // A token alone makes unsafe requests viable: BAS proxy routes deliver
252
- // tokens but strip cookies, and the backend accepts the token on the
253
- // proxy-established Basic-auth session. cookieCount stays reported for
254
- // diagnosis; setup's override validation requires cookies separately.
255
- sessionUsable: Boolean(session?.token)
256
- };
257
- }
258
-
259
-
260
- async function forward(method, requestUrl, requestHeaders, body, session) {
261
- const target = requestUrl;
262
- const cookieTarget = resolveCookieTarget(target);
263
- const cookieJar = session?.jar || sharedCookieJar;
264
- if (!session) await seedRequestCookies(cookieJar, requestHeaders.cookie, cookieTarget);
265
- const sessionCookie = await cookieHeader(cookieJar, cookieTarget);
266
- const headers = headersFrom(requestHeaders, {
267
- ...(session?.token ? { 'x-csrf-token': session.token } : {}),
268
- ...(sessionCookie ? { cookie: sessionCookie } : {})
269
- });
270
- const response = await sendWithFallback(target, method, headers, body);
271
- const responseBody = Buffer.from(await response.arrayBuffer());
272
- if (session) await recordCookies(session.jar, response.headers, cookieTarget, log, destination.name);
273
- await recordCookies(sharedCookieJar, response.headers, cookieTarget, log, destination.name);
274
- return { response, body: responseBody };
275
- }
276
-
277
- // Self-healing mode 1: when SAP rejects a token (expired, rotated, or
278
- // invalidated server-side), drop the cached session and retry with a
279
- // freshly fetched token paired with its own cookies. Bounded retries keep
280
- // a failing destination from looping forever.
281
- async function forwardWithRetry(method, requestUrl, requestHeaders, body, failures = 0) {
282
- const unsafe = UNSAFE.has(method);
283
- const session = unsafe ? await fetchToken(requestHeaders) : null;
284
- if (unsafe && !session?.token) {
285
- stats.csrfSessionFailures += 1;
286
- const message = `BAS destination relay could not obtain a CSRF token for ${requestUrl.pathname}; no unsafe request was sent`;
287
- log(`[${destination.name}] ${message}`);
288
- const status = session?.httpStatus >= 400 ? session.httpStatus : 503;
289
- const response = new Response(message, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
290
- return { response, body: Buffer.from(message) };
291
- }
292
- // A token without cookies is still sent: BAS proxy routes deliver tokens
293
- // but strip Set-Cookie, and the backend accepts the token on the Basic-
294
- // auth session the proxy establishes per hop. A retry cannot help there
295
- // (a fresh token still arrives cookie-less), so a CSRF rejection on a
296
- // cookie-less session is surfaced instead of retried.
297
- const tokenOnly = unsafe && !session.cookieCount;
298
- const { response, body: responseBody } = await forward(method, requestUrl, requestHeaders, body, session);
299
- const rejected = csrfFailure(response.status, response.headers, responseBody);
300
- if (unsafe && rejected && !tokenOnly && failures < maxFailures) {
301
- log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${maxFailures})`);
302
- stats.csrfRetries += 1;
303
- tokenCache.delete(CSRF_FETCH_PATH);
304
- return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
305
- }
306
- if (unsafe && rejected && tokenOnly) {
307
- log(`[${destination.name}] BAS relay CSRF token rejected without a session cookie for ${requestUrl.pathname}; not retried (the configured destination route delivered no cookies)`);
308
- } else if (unsafe && rejected && failures >= maxFailures) {
309
- log(`[${destination.name}] BAS relay stopped after ${maxFailures} CSRF session retries for ${requestUrl.pathname}`);
310
- }
311
- return { response, body: responseBody };
312
- }
313
-
314
- const server = http.createServer((req, res) => {
315
- void (async () => {
316
- const method = String(req.method || 'GET').toUpperCase();
317
- const requestUrl = new URL(req.url || '/', base);
318
- stats.requests += 1;
319
- const body = UNSAFE.has(method) ? await readRequestBody(req) : Buffer.alloc(0);
320
- const { response, body: responseBody } = await forwardWithRetry(method, requestUrl, req.headers, body);
321
- const responseHeaders = {};
322
- // SAP sessions commonly set several cookies at once; a plain object
323
- // assignment would collapse them to the last value. undici exposes the
324
- // full list via getSetCookie, and Node's http server serializes an
325
- // array as repeated headers.
326
- const setCookies = response.headers.getSetCookie?.() || [];
327
- if (setCookies.length) responseHeaders['set-cookie'] = setCookies;
328
- for (const [name, value] of response.headers) {
329
- const lower = name.toLowerCase();
330
- // undici decompresses response bodies; the stale content-encoding and
331
- // content-length would misdescribe the plain bytes handed to VSP.
332
- if (lower === 'set-cookie' || HOP_BY_HOP.has(lower) || lower === 'content-encoding' || lower === 'content-length') continue;
333
- responseHeaders[name] = value;
334
- }
335
- responseHeaders['x-sap-ai-dev-toolkit-relay'] = 'bas-destination';
336
- res.writeHead(response.status, response.statusText, responseHeaders);
337
- res.end(responseBody);
338
- })().catch(error => {
339
- if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'connection': 'close' });
340
- // Proxy and transport errors can embed the full proxy URL including
341
- // userinfo; redact before the body reaches the child (and the tool result).
342
- res.end(`BAS destination relay failed: ${redactText(error.message)}\n`);
343
- });
344
- });
345
-
346
- // Keep an error listener for the server's whole lifetime: after listen()
347
- // succeeds, a later 'error' event (port collision cleanup, socket reset)
348
- // with no listener would be an uncaught exception that kills the process.
349
- server.on('error', error => {
350
- log(`[${destination.name}] BAS relay server error: ${redactText(error.message)}`);
351
- });
352
- // Bind eagerly and report the real port through `ready`. Callers must
353
- // await `ready` before handing the URL to a VSP child; binding failures
354
- // surface as a rejected promise instead of a silently wrong guessed port.
355
- const listening = new Promise((resolve, reject) => {
356
- server.once('error', reject);
357
- server.listen(0, '127.0.0.1', () => {
358
- server.removeListener('error', reject);
359
- resolve(server.address().port);
360
- });
361
- });
362
- const ready = listening.then(port => `http://127.0.0.1:${port}`).catch(error => {
363
- log(`[${destination.name}] BAS relay failed to start: ${error.message}`);
364
- throw error;
365
- });
366
- return {
367
- ready,
368
- stats,
369
- probeCsrfSession,
370
- close: async () => {
371
- await dispatcher?.close?.();
372
- await tunnelDispatcher?.close?.();
373
- server.closeAllConnections?.();
374
- await new Promise(resolve => server.close(() => resolve()));
375
- }
376
- };
377
- }