sap-ai-dev-toolkit 0.4.2 → 0.5.0

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
@@ -456,9 +456,10 @@ Credentials, cookies, SAP usernames, passwords, and raw BAS destination payloads
456
456
  The configuration path is selected in this order:
457
457
 
458
458
  1. `SAP_AI_DEV_MCP_CONFIG`, when set.
459
- 2. `BAS_VSP_MCP_CONFIG`, when set for compatibility with earlier releases.
460
- 3. An existing MCP user configuration detected automatically.
461
- 4. The default MCP user configuration location.
459
+ 2. `SAP_AI_DEV_TOOLKIT_MCP_CONFIG`, when set.
460
+ 3. `BAS_VSP_MCP_CONFIG`, when set for compatibility with earlier releases.
461
+ 4. An existing MCP user configuration detected automatically.
462
+ 5. The default MCP user configuration location.
462
463
 
463
464
  Set `SAP_AI_DEV_MCP_CONFIG` to use a specific configuration file:
464
465
 
@@ -538,7 +539,9 @@ For a transport report, include exactly one `GetTransport` check whose `transpor
538
539
 
539
540
  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.
540
541
 
541
- `sap-ai-dev-toolkit --doctor` probes each discovered destination, starts VSP, calls `GetSystemInfo` when available, and lists MCP tools. `--doctor --json` prints redacted results and exits unsuccessfully when a required check fails.
542
+ `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 `/sap/bc/adt/datapreview/freestyle` through the runtime relay 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.
543
+
544
+ `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.
542
545
 
543
546
  `sap-ai-dev-toolkit --demo` starts an isolated server with sample ABAP objects, test and ATC results, a sample transport, and synthetic OData metadata and rows. Source edits and transport creation stay in process memory and disappear on exit. The demo does not discover or contact BAS or SAP destinations.
544
547
 
@@ -727,29 +730,29 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
727
730
  | `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`. |
728
731
  | `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. |
729
732
  | `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. |
730
- | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic (falls back to `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct except for OnPremise credential overrides, which require a BAS proxy tunnel). |
731
- | `SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES` | Bounded CSRF/session self-healing retries per unsafe request (default 3). |
733
+ | `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 except for OnPremise credential overrides, which require a BAS proxy tunnel. |
734
+ | `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. |
735
+ | `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
736
+ | `SAP_AI_DEV_TOOLKIT_MCP_CONFIG` | Branded compatibility alias for the MCP configuration path. |
737
+ | `BAS_VSP_MCP_CONFIG` | Backward-compatible alias for the MCP configuration path. |
738
+ | `BAS_VSP_BINARY` | Trusted prebuilt VSP executable; skips Go and binary provisioning. |
739
+ | `BAS_VSP_BINARY_URL` | Alternate VSP binary download URL. |
740
+ | `BAS_VSP_CACHE_DIR` | Binary cache directory. |
741
+ | `GO_BINARY` | Explicit Go executable used for provisioning when automatic Go installation is unavailable. |
742
+ | `SAP_AI_DEV_TOOLKIT_SKIP_PROBE=true` | Skip destination probes; useful for controlled diagnostics or fixtures. |
743
+ | `HTTP_PROXY` / `HTTPS_PROXY` | BAS proxy settings used for destination-list requests, destination probing, and child processes. |
744
+ | `NO_PROXY` | Proxy bypass list for BAS destination-list requests; `.dest` hosts remain routed through the BAS proxy. |
732
745
 
733
746
  #### Self-healing modes
734
747
 
735
748
  The relay between the VSP child and each BAS destination recovers from the failure modes that break ADT writes over `.dest` proxies:
736
749
 
737
- 1. **CSRF session pairing** — every token is stored together with the `Set-Cookie` state SAP returned alongside it, and both are replayed on POST/PUT/PATCH/DELETE. This fixes `403 CSRF token validation failed` caused by token/session separation.
738
- 2. **Session refresh** — when SAP rejects or rotates a session (401/403 after a token was already accepted), the cached session is dropped, a fresh token+cookie pair is fetched, and the request retried, bounded by the retry limit.
750
+ 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. If SAP returns a token without a usable session cookie, the relay fails closed and does not send the unsafe request.
751
+ 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.
739
752
  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.
740
753
  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.
741
754
  5. **Direct connect for Internet destinations** — the BAS `.dest` proxy strips SAP `Set-Cookie` headers, which makes CSRF token/session binding impossible for ADT writes (`403 CSRF token validation failed`). When you select an Internet destination with BasicAuthentication, setup asks whether to override its credentials. A Yes prompts for SAP user/password and stores them in `sap-ai-dev-toolkit-credentials.json` **next to** `mcp.json` (never inside it, permissions `0600`). The relay connects straight to the backend host so cookies survive and ADT writes can use the paired CSRF session.
742
755
  6. **Cloud Connector credential overrides** — setup also offers per-destination overrides for BasicAuthentication OnPremise destinations. BAS OnPremise credentials travel through an HTTP CONNECT tunnel to the BAS `.dest` endpoint, retaining its Cloud Connector mapping; setup saves the override only after a read-only probe returns both a CSRF token and a session cookie. If that route check fails, the override is not saved. Cloud Foundry OnPremise overrides replace only the SAP backend user/password; Connectivity service-key authentication and its proxy route remain unchanged. PrincipalPropagation is never overridden. Rerun `sap-ai-dev --setup` to change credentials or answer No to disable a stored override. Deselecting a destination (or selecting none) removes stale overrides.
743
- | `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP user configuration path. |
744
- | `BAS_VSP_MCP_CONFIG` | Backward-compatible alias for the MCP user configuration path. |
745
- | `BAS_VSP_BINARY` | Trusted prebuilt VSP executable; skips Go and binary provisioning. |
746
- | `BAS_VSP_BINARY_URL` | Alternate VSP binary download URL. |
747
- | `BAS_VSP_CACHE_DIR` | Binary cache directory. |
748
- | `GO_BINARY` | Explicit Go executable used for provisioning when automatic Go installation is unavailable. |
749
- | `SAP_AI_DEV_TOOLKIT_SKIP_PROBE=true` | Skip destination probes; useful for controlled diagnostics or fixtures. |
750
- | `HTTP_PROXY` / `HTTPS_PROXY` | BAS proxy settings used for destination-list requests, destination probing, and child processes. |
751
- | `NO_PROXY` | Proxy bypass list for BAS destination-list requests; `.dest` hosts remain routed through the BAS proxy. |
752
-
753
756
  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.
754
757
 
755
758
  <a id="troubleshooting"></a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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,6 +12,7 @@
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",
15
16
  "undici": "^6.21.3",
16
17
  "zod": "4.6.5"
17
18
  },
@@ -1,5 +1,7 @@
1
1
  import http from 'node:http';
2
2
  import { fetch as undiciFetch, ProxyAgent } from 'undici';
3
+ import { CookieJar } from 'tough-cookie';
4
+ import { brandedEnvValue } from './branding.mjs';
3
5
 
4
6
  const DEFAULT_PROXY = 'http://127.0.0.1:8887';
5
7
  const MAX_BODY_BYTES = 64 * 1024 * 1024;
@@ -14,7 +16,8 @@ const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
14
16
  // request. Without this pairing SAP sees a token from a foreign session and
15
17
  // answers "CSRF token validation failed" with HTTP 403.
16
18
  const TOKEN_TTL_MS = 15 * 60 * 1000;
17
- const MAX_TOKEN_FAILURES = 3;
19
+ const DEFAULT_MAX_TOKEN_FAILURES = 3;
20
+ const MAX_CONFIGURED_TOKEN_FAILURES = 10;
18
21
  const MAX_TUNNEL_FALLBACKS = 2;
19
22
  // Carrying a POST's content-length on the token-fetch GET would make the
20
23
  // proxy wait for a body that never arrives, so the relay drops body headers.
@@ -63,23 +66,49 @@ function csrfFailure(status, headers, body) {
63
66
  );
64
67
  }
65
68
 
66
- function authFailure(status) {
67
- return status === 401 || status === 403;
69
+ function maxTokenFailures(env, log, destinationName) {
70
+ const configured = brandedEnvValue(env, 'MAX_CSRF_RETRIES');
71
+ if (configured === undefined || configured === '') return DEFAULT_MAX_TOKEN_FAILURES;
72
+ const parsed = Number(configured);
73
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
74
+ log(`[${destinationName}] invalid CSRF retry setting; using the default of ${DEFAULT_MAX_TOKEN_FAILURES}`);
75
+ return DEFAULT_MAX_TOKEN_FAILURES;
76
+ }
77
+ return Math.min(parsed, MAX_CONFIGURED_TOKEN_FAILURES);
78
+ }
79
+
80
+ function copyCookieJar(jar) {
81
+ return CookieJar.deserializeSync(jar.serializeSync());
68
82
  }
69
83
 
70
- function cookieHeader(jar) {
71
- return jar.size ? [...jar].map(([name, value]) => `${name}=${value}`).join('; ') : undefined;
84
+ async function cookieHeader(jar, target) {
85
+ return await jar.getCookieString(target.href) || undefined;
72
86
  }
73
87
 
74
- function recordCookies(jar, headers) {
75
- for (const value of headers.getSetCookie?.() || []) {
76
- const [pair] = String(value).split(';');
88
+ async function seedRequestCookies(jar, requestCookie, target) {
89
+ for (const pair of String(requestCookie || '').split(';')) {
77
90
  const separator = pair.indexOf('=');
78
91
  if (separator <= 0) continue;
79
92
  const name = pair.slice(0, separator).trim();
93
+ const value = pair.slice(separator + 1).trim();
80
94
  if (!name) continue;
81
- if (/expires=thu, 01 jan 1970/i.test(String(value))) jar.delete(name);
82
- else jar.set(name, pair.slice(separator + 1).trim());
95
+ try {
96
+ await jar.setCookie(`${name}=${value}; Path=/`, target.href, { ignoreError: true });
97
+ } catch {
98
+ // Invalid caller-provided cookie fragments are ignored; SAP response
99
+ // cookies remain authoritative for the destination session.
100
+ }
101
+ }
102
+ }
103
+
104
+ async function recordCookies(jar, headers, target, log, destinationName) {
105
+ const values = headers.getSetCookie?.() || (headers.get('set-cookie') ? [headers.get('set-cookie')] : []);
106
+ for (const value of values) {
107
+ try {
108
+ await jar.setCookie(String(value), target.href, { ignoreError: true });
109
+ } catch {
110
+ log(`[${destinationName}] BAS relay ignored an invalid Set-Cookie header`);
111
+ }
83
112
  }
84
113
  }
85
114
 
@@ -104,6 +133,16 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
104
133
  // must keep the BAS .dest route (and its Cloud Connector mapping), so they
105
134
  // use HTTP CONNECT through the BAS proxy instead of credentials.host.
106
135
  const directBase = directHost ? new URL(String(directHost)) : null;
136
+ const cookieBase = directBase || (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
+ })());
107
146
  if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
108
147
  if (tunnelMode) log(`[${destination.name}] BAS relay using a BAS proxy tunnel for the OnPremise destination`);
109
148
  // Proxy resolution: an explicitly configured proxy wins (an explicit empty
@@ -111,7 +150,8 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
111
150
  // imply the default BAS proxy; any other host (tests, direct URLs) is
112
151
  // reached directly so the relay never routes loopback traffic through a
113
152
  // foreign proxy.
114
- const explicitProxy = env.HTTP_PROXY ?? env.http_proxy;
153
+ const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
154
+ const explicitProxy = brandedProxy !== undefined ? brandedProxy : (env.HTTP_PROXY ?? env.http_proxy);
115
155
  const proxyUrl = directBase ? '' : (explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : ''));
116
156
  if (tunnelMode && !proxyUrl) throw new Error('OnPremise credential overrides require the BAS destination proxy to preserve the Cloud Connector route');
117
157
  const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
@@ -121,15 +161,17 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
121
161
  const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
122
162
  let tunneled = tunnelMode;
123
163
  // Observable self-healing counters, surfaced by --doctor and tests.
124
- const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, tunnelFallbacks: 0, direct: Boolean(directBase), proxyTunnel: tunnelMode };
125
- // Per-path CSRF sessions: { token, jar, fetchedAt } where jar holds the
126
- // Set-Cookie state SAP returned together with the token.
164
+ const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct: Boolean(directBase), proxyTunnel: tunnelMode };
165
+ const maxFailures = maxTokenFailures(env, log, destination.name);
166
+ // The shared jar captures SAP cookies from safe reads as well as CSRF fetches.
167
+ // Each cached token gets its own snapshot so later responses cannot silently
168
+ // pair an old token with a different session.
169
+ const sharedCookieJar = new CookieJar();
127
170
  const tokenCache = new Map();
171
+ const tokenFetches = new Map();
128
172
 
129
- function rememberSession(key, token, headers, httpStatus) {
130
- const jar = new Map();
131
- recordCookies(jar, headers);
132
- tokenCache.set(key, { token, jar, fetchedAt: Date.now(), httpStatus });
173
+ function resolveCookieTarget(target) {
174
+ return new URL(`${target.pathname}${target.search}`, cookieBase);
133
175
  }
134
176
 
135
177
  async function send(target, method, headers, body) {
@@ -158,23 +200,48 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
158
200
  return send(target, method, headers, body);
159
201
  }
160
202
 
161
- async function fetchToken(requestUrl, requestHeaders) {
203
+ async function fetchTokenFresh(requestUrl, requestHeaders) {
162
204
  const key = requestUrl.pathname;
163
- const cached = tokenCache.get(key);
164
- if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
165
205
  stats.csrfFetches += 1;
166
- const response = await sendWithFallback(resolveTarget(requestUrl), 'GET', authHeaders(fetchHeaders(requestHeaders, {
206
+ const target = resolveTarget(requestUrl);
207
+ const cookieTarget = resolveCookieTarget(target);
208
+ const jar = copyCookieJar(sharedCookieJar);
209
+ await seedRequestCookies(jar, requestHeaders.cookie, cookieTarget);
210
+ const outgoingCookie = await cookieHeader(jar, cookieTarget);
211
+ const response = await sendWithFallback(target, 'GET', authHeaders(fetchHeaders(requestHeaders, {
167
212
  'x-csrf-token': 'Fetch',
168
213
  accept: requestHeaders.accept || 'application/xml,*/*',
169
- ...(cached?.jar?.size ? { cookie: cookieHeader(cached.jar) } : {})
214
+ ...(outgoingCookie ? { cookie: outgoingCookie } : {})
170
215
  })));
171
216
  await response.arrayBuffer().catch(() => undefined);
172
217
  const token = response.headers.get('x-csrf-token');
173
- if (token && token.toLowerCase() !== 'required') {
174
- rememberSession(key, token, response.headers, response.status);
175
- return tokenCache.get(key);
218
+ await recordCookies(jar, response.headers, cookieTarget, log, destination.name);
219
+ await recordCookies(sharedCookieJar, response.headers, cookieTarget, log, destination.name);
220
+ const cookies = await jar.getCookies(cookieTarget.href);
221
+ const session = {
222
+ token: response.ok && token && token.toLowerCase() !== 'required' ? token : null,
223
+ jar,
224
+ fetchedAt: Date.now(),
225
+ httpStatus: response.status,
226
+ cookieCount: cookies.length
227
+ };
228
+ if (session.token) tokenCache.set(key, session);
229
+ return session;
230
+ }
231
+
232
+ async function fetchToken(requestUrl, requestHeaders = {}) {
233
+ const key = requestUrl.pathname;
234
+ const cached = tokenCache.get(key);
235
+ if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
236
+ const pending = tokenFetches.get(key);
237
+ if (pending) return pending;
238
+ const fetching = fetchTokenFresh(requestUrl, requestHeaders);
239
+ tokenFetches.set(key, fetching);
240
+ try {
241
+ return await fetching;
242
+ } finally {
243
+ if (tokenFetches.get(key) === fetching) tokenFetches.delete(key);
176
244
  }
177
- return null;
178
245
  }
179
246
 
180
247
  async function probeCsrfSession(path = '/sap/bc/adt/discovery') {
@@ -183,7 +250,8 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
183
250
  return {
184
251
  httpStatus: session?.httpStatus || 0,
185
252
  tokenReceived: Boolean(session?.token),
186
- cookieCount: session?.jar?.size || 0
253
+ cookieCount: session?.cookieCount || 0,
254
+ sessionUsable: Boolean(session?.token && session.cookieCount > 0)
187
255
  };
188
256
  }
189
257
 
@@ -201,13 +269,19 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
201
269
  }
202
270
 
203
271
  async function forward(method, requestUrl, requestHeaders, body, session) {
272
+ const target = resolveTarget(requestUrl);
273
+ const cookieTarget = resolveCookieTarget(target);
274
+ const cookieJar = session?.jar || sharedCookieJar;
275
+ if (!session) await seedRequestCookies(cookieJar, requestHeaders.cookie, cookieTarget);
276
+ const sessionCookie = await cookieHeader(cookieJar, cookieTarget);
204
277
  const headers = authHeaders(headersFrom(requestHeaders, {
205
278
  ...(session?.token ? { 'x-csrf-token': session.token } : {}),
206
- ...(session?.jar?.size ? { cookie: cookieHeader(session.jar) } : {})
279
+ ...(sessionCookie ? { cookie: sessionCookie } : {})
207
280
  }));
208
- const response = await sendWithFallback(resolveTarget(requestUrl), method, headers, body);
281
+ const response = await sendWithFallback(target, method, headers, body);
209
282
  const responseBody = Buffer.from(await response.arrayBuffer());
210
- if (session) recordCookies(session.jar, response.headers);
283
+ if (session) await recordCookies(session.jar, response.headers, cookieTarget, log, destination.name);
284
+ await recordCookies(sharedCookieJar, response.headers, cookieTarget, log, destination.name);
211
285
  return { response, body: responseBody };
212
286
  }
213
287
 
@@ -218,15 +292,28 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
218
292
  async function forwardWithRetry(method, requestUrl, requestHeaders, body, failures = 0) {
219
293
  const unsafe = UNSAFE.has(method);
220
294
  const session = unsafe ? await fetchToken(requestUrl, requestHeaders) : null;
221
- if (unsafe && !session) log(`[${destination.name}] BAS relay could not obtain CSRF token for ${requestUrl.pathname}`);
295
+ if (unsafe && (!session?.token || !session.cookieCount)) {
296
+ stats.csrfSessionFailures += 1;
297
+ const detail = !session?.token
298
+ ? 'could not obtain a CSRF token'
299
+ : 'received a CSRF token without a session cookie';
300
+ const message = `BAS destination relay ${detail} for ${requestUrl.pathname}; no unsafe request was sent`;
301
+ log(`[${destination.name}] ${message}`);
302
+ const status = session?.httpStatus >= 400 ? session.httpStatus : 503;
303
+ const response = new Response(message, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
304
+ return { response, body: Buffer.from(message) };
305
+ }
222
306
  const { response, body: responseBody } = await forward(method, requestUrl, requestHeaders, body, session);
223
- const rejected = csrfFailure(response.status, response.headers, responseBody) || (authFailure(response.status) && session);
224
- if (unsafe && rejected && failures < MAX_TOKEN_FAILURES) {
225
- log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${MAX_TOKEN_FAILURES})`);
307
+ const rejected = csrfFailure(response.status, response.headers, responseBody);
308
+ if (unsafe && rejected && failures < maxFailures) {
309
+ log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${maxFailures})`);
226
310
  stats.csrfRetries += 1;
227
311
  tokenCache.delete(requestUrl.pathname);
228
312
  return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
229
313
  }
314
+ if (unsafe && rejected && failures >= maxFailures) {
315
+ log(`[${destination.name}] BAS relay stopped after ${maxFailures} CSRF session retries for ${requestUrl.pathname}`);
316
+ }
230
317
  return { response, body: responseBody };
231
318
  }
232
319
 
@@ -120,8 +120,9 @@ function proxyBypasses(url, noProxy) {
120
120
 
121
121
  function proxyDispatcher(target, env) {
122
122
  const url = new URL(target);
123
- const httpProxy = env.http_proxy ?? env.HTTP_PROXY ?? '';
124
- const httpsProxy = env.https_proxy ?? env.HTTPS_PROXY ?? '';
123
+ const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
124
+ const httpProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.http_proxy ?? env.HTTP_PROXY ?? '');
125
+ const httpsProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.https_proxy ?? env.HTTPS_PROXY ?? '');
125
126
  const proxy = url.protocol === 'https:' ? httpsProxy || httpProxy : httpProxy;
126
127
  const noProxy = env.no_proxy ?? env.NO_PROXY ?? '';
127
128
  if (!proxy || proxyBypasses(url, noProxy)) return undefined;
@@ -182,7 +183,7 @@ export async function probeADT(destination, options = {}) {
182
183
  const url = `${destinationUrl(destination.name)}/sap/bc/adt/discovery`;
183
184
  if (options.skipProbe) return { status: 'skipped', available: true, url };
184
185
  try {
185
- const result = options.probeImpl ? await options.probeImpl(url, options) : await requestProbe(url, options.proxyUrl || DEFAULT_PROXY, options.timeoutMs || 5000);
186
+ const result = options.probeImpl ? await options.probeImpl(url, options) : await requestProbe(url, options.proxyUrl ?? DEFAULT_PROXY, options.timeoutMs || 5000);
186
187
  if ([401, 403].includes(result.status) || (result.status >= 200 && result.status < 300)) return { status: result.status === 401 || result.status === 403 ? 'auth-required' : 'available', available: true, httpStatus: result.status, url };
187
188
  if (result.status === 404) return { status: 'not-found', available: false, httpStatus: result.status, url };
188
189
  return { status: 'unavailable', available: false, httpStatus: result.status, url };
@@ -198,7 +199,9 @@ export async function discoverDestinations(options = {}) {
198
199
  if (!list) throw new Error(`Unrecognized BAS destination response shape; top-level keys: ${Object.keys(body || {}).map(String).join(', ') || '<none>'}`);
199
200
  const allow = text(brandedEnvValue(env, 'DESTINATION')).split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
200
201
  const normalized = list.map(normalizeDestination).filter(item => item.name).filter(item => !allow.length || allow.includes(item.name.toLowerCase()));
201
- const proxyUrl = env.HTTP_PROXY || env.http_proxy || DEFAULT_PROXY;
202
+ const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
203
+ const configuredProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.HTTP_PROXY ?? env.http_proxy);
204
+ const proxyUrl = configuredProxy !== undefined ? configuredProxy : DEFAULT_PROXY;
202
205
  const skipProbe = String(brandedEnvValue(env, 'SKIP_PROBE') || '').toLowerCase() === 'true' || options.skipProbe;
203
206
  const result = [];
204
207
  for (const destination of normalized.sort((a, b) => a.name.localeCompare(b.name))) {
package/src/launcher.mjs CHANGED
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { discoverDestinations, remediation, statusRows } from './bas-discovery.mjs';
7
7
  import { findBinary } from './binary.mjs';
8
8
  import { MCPProxy } from './mcp-proxy.mjs';
9
- import { installMcpConfig } from './mcp-config.mjs';
9
+ import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
10
10
  import { runSetup } from './setup.mjs';
11
11
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
12
12
  import { withBrandedEnvironment } from './branding.mjs';
@@ -30,7 +30,7 @@ function usage() {
30
30
  ' --list-destinations List discovered BAS destinations',
31
31
  ' --list-destinations --json Print redacted JSON status',
32
32
  ' --check Probe destination availability',
33
- ' --doctor Check destination, VSP, SAP, and MCP connectivity',
33
+ ' --doctor Check SAP/MCP connectivity and repair safe toolkit config drift',
34
34
  ' --doctor --json Print redacted JSON diagnostics',
35
35
  ' --demo Start an isolated, offline SAP MCP playground',
36
36
  ' --help, -h Show this help and exit',
@@ -58,6 +58,20 @@ async function runDoctor(destinations) {
58
58
  try { binary = await binaryOrError(); }
59
59
  catch (error) { binaryError = error.message; }
60
60
 
61
+
62
+ try {
63
+ const repair = await repairManagedMcpConfig(destinations, {
64
+ env: runtimeEnv,
65
+ discoveryComplete: true,
66
+ packageVersion: pkg.version
67
+ });
68
+ const detail = repair.skipped
69
+ ? repair.skipped
70
+ : `${repair.repaired} managed BAS entr${repair.repaired === 1 ? 'y' : 'ies'} repaired; ${repair.managedEntries || 0} managed BAS entries recognized`;
71
+ checks.push(doctorRow('-', 'MCP config repair', 'passed', detail));
72
+ } catch (error) {
73
+ checks.push(doctorRow('-', 'MCP config repair', 'failed', redactText(error.message || error).slice(0, 300)));
74
+ }
61
75
  for (const destination of destinations) {
62
76
  const probe = destination.probe;
63
77
  const probeStatus = !probe || probe.status === 'skipped' ? 'skipped' : (probe.available === true ? 'passed' : 'failed');
@@ -78,7 +92,7 @@ async function runDoctor(destinations) {
78
92
  checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
79
93
  const relayedDestination = proxy.children[0]?.destination;
80
94
  if (relayedDestination?.relay) {
81
- checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); CSRF token/session pairing active`));
95
+ checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); runtime session preflight pending`));
82
96
  } else if (destination.source === 'cloud-foundry') {
83
97
  checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Cloud Foundry destinations connect through the connectivity proxy directly.'));
84
98
  } else if (String(runtimeEnv.SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY || '').toLowerCase() === 'true') {
@@ -96,14 +110,37 @@ async function runDoctor(destinations) {
96
110
  const tools = listed.result?.tools || [];
97
111
  const localNames = new Set(['LintABAP', 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration', 'GenerateRAPRegressionSuite', 'RunRAPRegressionSuite']);
98
112
  const upstreamCount = tools.filter(tool => !localNames.has(tool.name.slice(tool.name.indexOf('__') + 2))).length;
99
- checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} VSP tools available`));
113
+ checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} VSP tools and ${tools.length} total MCP tools returned; chat-picker binding is host-managed`));
100
114
  const systemInfo = tools.find(tool => tool.name.endsWith('__GetSystemInfo'));
101
115
  if (!systemInfo) {
102
116
  checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
103
117
  } else {
104
- const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
105
- const failed = inspected?.error || inspected?.result?.isError;
106
- 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'));
118
+ let sessionReady = true;
119
+ if (relayedDestination?.relay?.probeCsrfSession) {
120
+ let session;
121
+ let preflightError;
122
+ try {
123
+ session = await relayedDestination.relay.probeCsrfSession('/sap/bc/adt/datapreview/freestyle');
124
+ } catch (error) {
125
+ preflightError = redactText(error.message || error).slice(0, 250);
126
+ }
127
+ sessionReady = session?.sessionUsable === true;
128
+ const status = sessionReady ? 'passed' : 'failed';
129
+ const stats = relayedDestination.relay.stats || {};
130
+ const detail = preflightError
131
+ ? `preflight request failed: ${preflightError}`
132
+ : `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}`;
133
+ checks.push(doctorRow(destination.name, 'BAS relay CSRF preflight', status, detail));
134
+ if (!sessionReady) {
135
+ const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session. For OnPremise BasicAuth, run sap-ai-dev --setup and use a validated BAS proxy tunnel credential override.';
136
+ checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', preflightError ? `${remediation} Relay error: ${preflightError}` : remediation));
137
+ }
138
+ }
139
+ if (sessionReady) {
140
+ const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
141
+ const failed = inspected?.error || inspected?.result?.isError;
142
+ 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
+ }
107
144
  }
108
145
  } catch (error) {
109
146
  const detail = redactText(error.message || error).slice(0, 300);
@@ -82,7 +82,8 @@ export async function resolveMcpServerCommand(env = process.env) {
82
82
  }
83
83
 
84
84
  export async function resolveMcpConfigPath(env = process.env) {
85
- const configuredPath = brandedEnvValue(env, 'MCP_CONFIG');
85
+ const configuredPath = [env.SAP_AI_DEV_MCP_CONFIG, brandedEnvValue(env, 'MCP_CONFIG')]
86
+ .find(value => typeof value === 'string' && value.length > 0);
86
87
  if (configuredPath) return configuredPath;
87
88
  const home = env.HOME || homedir();
88
89
  const candidates = [
@@ -204,6 +205,22 @@ function destinationSource(env) {
204
205
  return env?.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE ?? env?.BAS_VSP_DESTINATION_SOURCE;
205
206
  }
206
207
 
208
+ function isManagedBasDestinationEntry(entry) {
209
+ const currentCommands = new Set(['sap-ai-dev', 'sap-ai-dev-toolkit']);
210
+ const commandName = typeof entry?.command === 'string'
211
+ ? entry.command.slice(Math.max(entry.command.lastIndexOf('/'), entry.command.lastIndexOf('\\')) + 1).replace(/\.(?:cmd|exe)$/i, '')
212
+ : '';
213
+ const currentCommand = currentCommands.has(entry?.command) || currentCommands.has(commandName);
214
+ const currentNpxLauncher = entry?.command === 'npx'
215
+ && Array.isArray(entry.args)
216
+ && entry.args.some(argument => currentCommands.has(argument))
217
+ && entry.args.some(argument => typeof argument === 'string' && /^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/.test(argument));
218
+ return entry?.BAS_EXT === 'true'
219
+ && (currentCommand || currentNpxLauncher)
220
+ && typeof destinationValue(entry?.env) === 'string'
221
+ && destinationSource(entry.env) === undefined;
222
+ }
223
+
207
224
  function collectCloudFoundryKeyReferences(config, managedOnly) {
208
225
  const refs = new Map();
209
226
  for (const entry of Object.values(config?.servers || {})) {
@@ -292,7 +309,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
292
309
  && typeof destinationValue(entry?.env) === 'string'
293
310
  && (destinationSource(entry.env) === 'cloud-foundry'
294
311
  ? typeof entry.env.BAS_CF_DESTINATION_KEY === 'string'
295
- : entry.env.BAS_VSP_DESTINATION_SOURCE === undefined);
312
+ : destinationSource(entry.env) === undefined);
296
313
  if (!LEGACY_MCP_SERVER_PREFIXES.some(prefix => name.startsWith(prefix)) && !managed && !isCompanionServer(entry)) servers[name] = entry;
297
314
  }
298
315
  for (const name of Object.keys(generated)) {
@@ -305,6 +322,53 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
305
322
  return { path, servers: generated };
306
323
  }
307
324
 
325
+ export async function repairManagedMcpConfig(discoveredDestinations, { env = process.env, path, discoveryComplete = false, packageVersion } = {}) {
326
+ const configPath = path || await resolveMcpConfigPath(env);
327
+ if (!discoveryComplete) {
328
+ return { path: configPath, changed: false, repaired: 0, skipped: 'Destination discovery was incomplete.' };
329
+ }
330
+ if (!Array.isArray(discoveredDestinations)) throw new Error('Destination discovery result must be an array');
331
+ const basDestinations = new Map(discoveredDestinations
332
+ .filter(destination => destination?.source !== 'cloud-foundry' && destination?.name)
333
+ .map(destination => [String(destination.name), destination]));
334
+ const config = await readConfig(configPath);
335
+ const managedEntries = Object.values(config.servers).filter(isManagedBasDestinationEntry).length;
336
+ if (!basDestinations.size) return { path: configPath, changed: false, repaired: 0, managedEntries };
337
+ const localCommand = await resolveMcpServerCommand(env);
338
+ let repaired = 0;
339
+ for (const [serverName, entry] of Object.entries(config.servers)) {
340
+ if (!isManagedBasDestinationEntry(entry)) continue;
341
+ const destinationName = destinationValue(entry.env);
342
+ if (generatedServerName(destinationName) !== serverName) continue;
343
+ const destination = basDestinations.get(destinationName);
344
+ if (!destination) continue;
345
+ const expected = buildMcpEntries([destination], env)[serverName];
346
+ const nextEnvironment = { ...entry.env, ...expected.env };
347
+ if (Object.hasOwn(entry.env, 'SAP_ALLOW_TRANSPORTABLE_EDITS')) {
348
+ nextEnvironment.SAP_ALLOW_TRANSPORTABLE_EDITS = entry.env.SAP_ALLOW_TRANSPORTABLE_EDITS;
349
+ }
350
+ delete nextEnvironment.BAS_VSP_DESTINATION;
351
+ delete nextEnvironment.BAS_VSP_DESTINATION_SOURCE;
352
+ const nextEntry = { ...entry, type: 'stdio', env: nextEnvironment, BAS_EXT: 'true' };
353
+ if (entry.command === 'npx') {
354
+ if (packageVersion && Array.isArray(entry.args)) {
355
+ nextEntry.args = entry.args.map(argument => typeof argument === 'string' && /^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/.test(argument)
356
+ ? `--package=sap-ai-dev-toolkit@${packageVersion}`
357
+ : argument);
358
+ }
359
+ } else {
360
+ nextEntry.command = localCommand;
361
+ }
362
+ if (JSON.stringify(nextEntry) === JSON.stringify(entry)) continue;
363
+ config.servers[serverName] = nextEntry;
364
+ repaired += 1;
365
+ }
366
+
367
+ if (!repaired) return { path: configPath, changed: false, repaired: 0, managedEntries };
368
+ await writeConfig(configPath, config);
369
+ return { path: configPath, changed: true, repaired, managedEntries };
370
+ }
371
+
308
372
  export async function readMcpConfig(path) {
309
373
  return readConfig(path);
310
374
  }
package/src/mcp-proxy.mjs CHANGED
@@ -302,7 +302,10 @@ export class MCPProxy {
302
302
  destination = {
303
303
  ...originalDestination,
304
304
  url: relayUrl,
305
- relay: true,
305
+ relay: {
306
+ probeCsrfSession: relay.probeCsrfSession,
307
+ stats: relay.stats
308
+ },
306
309
  async close() {
307
310
  await originalClose?.();
308
311
  await relay.close();
@@ -11,20 +11,48 @@ const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
11
11
  // request carries BOTH the token and the session cookies that were set
12
12
  // together with it. This reproduces the 403 "CSRF token validation failed"
13
13
  // seen on the BAS .dest proxy path before the relay paired sessions.
14
- function sapLikeBackend({ neverAcceptUnsafe = false, rotateOnFetch = false } = {}) {
14
+ function sapLikeBackend({
15
+ neverAcceptUnsafe = false,
16
+ rotateOnFetch = false,
17
+ genericForbidden = false,
18
+ partialRefreshCookies = false,
19
+ omitCookies = false,
20
+ setCookieOnSafeGet = false,
21
+ secureSessionCookie = false
22
+ } = {}) {
15
23
  const state = { sessionCounter: 0, requests: [] };
16
24
  const server = http.createServer((req, res) => {
17
25
  state.requests.push({ method: req.method, path: req.url, headers: { ...req.headers } });
18
26
  if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
19
27
  state.sessionCounter += 1;
20
- res.writeHead(200, {
21
- 'x-csrf-token': `token-${state.sessionCounter}`,
22
- 'set-cookie': [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`, 'sap-usercontext=sap-client=100; path=/']
23
- });
28
+ const headers = { 'x-csrf-token': `token-${state.sessionCounter}` };
29
+ if (!omitCookies) {
30
+ headers['set-cookie'] = [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`];
31
+ if (!partialRefreshCookies || state.sessionCounter === 1) {
32
+ headers['set-cookie'].push('sap-usercontext=sap-client=100; path=/');
33
+ }
34
+ if (partialRefreshCookies) {
35
+ headers['set-cookie'].push(state.sessionCounter === 1
36
+ ? 'sap-stale=delete-me; path=/'
37
+ : 'sap-stale=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/');
38
+ }
39
+ if (secureSessionCookie) headers['set-cookie'].push('sap-secure=secure-value; Secure; Path=/');
40
+ }
41
+ res.writeHead(200, headers);
24
42
  res.end('<ok/>');
25
43
  return;
26
44
  }
45
+ if (req.method === 'GET' && req.url === '/sap/bc/adt/discovery' && setCookieOnSafeGet) {
46
+ res.writeHead(200, { 'set-cookie': 'bas-read-session=kept; path=/sap/bc/adt' });
47
+ res.end('<read/>');
48
+ return;
49
+ }
27
50
  if (UNSAFE_METHODS.includes(req.method)) {
51
+ if (genericForbidden) {
52
+ res.writeHead(403);
53
+ res.end('User is not authorized to change this object');
54
+ return;
55
+ }
28
56
  if (neverAcceptUnsafe) {
29
57
  res.writeHead(403, { 'x-csrf-token': 'Required' });
30
58
  res.end('CSRF token validation failed');
@@ -36,7 +64,11 @@ function sapLikeBackend({ neverAcceptUnsafe = false, rotateOnFetch = false } = {
36
64
  // Without rotation any still-live token+cookie pair is accepted; with
37
65
  // rotation only the newest issued session remains valid, matching SAP
38
66
  // server-side session invalidation.
39
- const valid = match && cookie.includes(`sap-session-${match[1]}=`) && (!rotateOnFetch || Number(match[1]) === state.sessionCounter);
67
+ const valid = match
68
+ && cookie.includes(`sap-session-${match[1]}=`)
69
+ && (!rotateOnFetch || Number(match[1]) === state.sessionCounter)
70
+ && (!partialRefreshCookies || cookie.includes('sap-usercontext=sap-client=100'))
71
+ && (!partialRefreshCookies || Number(match[1]) === 1 || !cookie.includes('sap-stale='));
40
72
  if (!valid) {
41
73
  res.writeHead(403, { 'x-csrf-token': 'Required' });
42
74
  res.end('CSRF token validation failed');
@@ -79,7 +111,7 @@ async function withBackend(options, handler) {
79
111
  const relay = createBasDestinationRelay(
80
112
  { name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
81
113
  {
82
- env: { HTTP_PROXY: '' },
114
+ env: { HTTP_PROXY: '', ...(options?.env || {}) },
83
115
  fetchImpl: options?.fetchImpl || ((url, init) => globalThis.fetch(url, init)),
84
116
  log: message => logs.push(message)
85
117
  }
@@ -115,6 +147,30 @@ test('reuses the cached CSRF session for subsequent unsafe requests', async () =
115
147
  });
116
148
  });
117
149
 
150
+ test('coalesces concurrent CSRF token fetches for the same ADT endpoint', async () => {
151
+ await withBackend(null, async ({ relayUrl, state }) => {
152
+ const responses = await Promise.all([
153
+ relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' }),
154
+ relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' })
155
+ ]);
156
+ assert.deepEqual(responses.map(response => response.status), [201, 201]);
157
+ assert.equal(state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch').length, 1);
158
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 2);
159
+ });
160
+ });
161
+
162
+ test('seeds the relay cookie jar from the incoming MCP request cookie', async () => {
163
+ await withBackend(null, async ({ relayUrl, state }) => {
164
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', {
165
+ method: 'POST',
166
+ headers: { cookie: 'sap-initial-session=from-vsp' },
167
+ body: '<x/>'
168
+ });
169
+ const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
170
+ assert.match(String(tokenFetch.headers.cookie), /sap-initial-session=from-vsp/);
171
+ });
172
+ });
173
+
118
174
  test('self-heals when SAP rotates the session: refetches token and cookies', async () => {
119
175
  await withBackend({ rotateOnFetch: true }, async ({ relay, relayUrl, state }) => {
120
176
  // A first POST establishes session token-1. SAP then invalidates it
@@ -129,6 +185,46 @@ test('self-heals when SAP rotates the session: refetches token and cookies', asy
129
185
  });
130
186
  });
131
187
 
188
+ test('merges partial session-cookie updates and applies cookie deletion on refresh', async () => {
189
+ await withBackend({ rotateOnFetch: true, partialRefreshCookies: true }, async ({ relayUrl, state }) => {
190
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
191
+ state.sessionCounter += 1;
192
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
193
+ assert.equal(response.status, 201, `partial cookie refresh must heal, got ${response.status}`);
194
+ const posts = state.requests.filter(request => request.method === 'POST');
195
+ assert.match(String(posts[2].headers.cookie), /sap-usercontext=sap-client=100/);
196
+ assert.doesNotMatch(String(posts[2].headers.cookie), /sap-stale=/);
197
+ });
198
+ });
199
+
200
+ test('retains cookies received on a safe GET for later CSRF requests', async () => {
201
+ await withBackend({ setCookieOnSafeGet: true }, async ({ relayUrl, state }) => {
202
+ await relayFetch(relayUrl, '/sap/bc/adt/discovery');
203
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
204
+ assert.equal(response.status, 201);
205
+ const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
206
+ assert.match(String(tokenFetch.headers.cookie), /bas-read-session=kept/);
207
+ });
208
+ });
209
+
210
+ test('does not retry generic authorization failures as CSRF errors', async () => {
211
+ await withBackend({ genericForbidden: true }, async ({ relayUrl, relay, state }) => {
212
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
213
+ assert.equal(response.status, 403);
214
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 1);
215
+ assert.equal(relay.stats.csrfRetries, 0);
216
+ });
217
+ });
218
+
219
+ test('does not repeat a CSRF failure when SAP returns a token without session cookies', async () => {
220
+ await withBackend({ omitCookies: true }, async ({ relayUrl, relay, state }) => {
221
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
222
+ assert.equal(response.status, 503, `unsafe calls must fail closed without a paired session, got ${response.status}`);
223
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 0);
224
+ assert.equal(relay.stats.csrfRetries, 0);
225
+ });
226
+ });
227
+
132
228
  test('stops retrying after bounded attempts and surfaces the failure', async () => {
133
229
  await withBackend({ neverAcceptUnsafe: true }, async ({ relay, relayUrl, state }) => {
134
230
  const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
@@ -139,6 +235,45 @@ test('stops retrying after bounded attempts and surfaces the failure', async ()
139
235
  });
140
236
  });
141
237
 
238
+ test('honors the configured bounded CSRF retry count', async () => {
239
+ await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '1' } }, async ({ relayUrl, relay, state }) => {
240
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
241
+ assert.equal(response.status, 403);
242
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 1 + 1);
243
+ assert.equal(relay.stats.csrfRetries, 1);
244
+ });
245
+ });
246
+
247
+ test('uses the default for invalid retry settings and caps excessive retry counts', async () => {
248
+ await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '1.5' } }, async ({ relayUrl, state }) => {
249
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
250
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 4, 'invalid values use the default of three retries');
251
+ });
252
+ await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '100' } }, async ({ relayUrl, state }) => {
253
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
254
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 11, 'retry counts are capped at ten');
255
+ });
256
+ });
257
+
258
+ test('prefers the documented branded proxy setting including an explicit direct-connection override', async t => {
259
+ let dispatchedWithProxy = false;
260
+ const relay = createBasDestinationRelay(
261
+ { name: 'TEST', url: 'http://test.dest', authentication: 'BasicAuthentication' },
262
+ {
263
+ env: { HTTP_PROXY: 'http://127.0.0.1:1', SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '' },
264
+ fetchImpl: async (_url, options = {}) => {
265
+ dispatchedWithProxy = Boolean(options.dispatcher);
266
+ return new Response('<ok/>', { status: 200, headers: { 'x-csrf-token': 'token', 'set-cookie': 'sap-session=s; path=/' } });
267
+ }
268
+ }
269
+ );
270
+ t.after(() => relay.close());
271
+ await relay.ready;
272
+ const probe = await relay.probeCsrfSession();
273
+ assert.equal(probe.sessionUsable, true);
274
+ assert.equal(dispatchedWithProxy, false, 'an explicit empty branded proxy setting means direct traffic');
275
+ });
276
+
142
277
  test('GET requests pass through without CSRF handling', async () => {
143
278
  await withBackend(null, async ({ relayUrl, state, relay }) => {
144
279
  const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery', { headers: { accept: 'application/xml' } });
@@ -189,7 +324,7 @@ test('falls back to a proxy tunnel when the absolute-form request is refused', a
189
324
  });
190
325
 
191
326
  test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF session', async t => {
192
- const { state, server } = sapLikeBackend();
327
+ const { state, server } = sapLikeBackend({ secureSessionCookie: true });
193
328
  const backendPort = await startServer(server);
194
329
  const connectTargets = [];
195
330
  const proxy = http.createServer();
@@ -224,12 +359,13 @@ test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF ses
224
359
  });
225
360
 
226
361
  const probe = await relay.probeCsrfSession();
227
- assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 2 });
362
+ assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 3, sessionUsable: true });
228
363
  const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
229
364
  assert.equal(response.status, 201, `expected a paired CSRF session, got ${response.status}: ${response.body}`);
230
365
  const post = state.requests.find(request => request.method === 'POST');
231
366
  assert.match(post.headers.authorization, /^Basic /);
232
367
  assert.match(String(post.headers.cookie), /sap-session-\d+=/);
368
+ assert.match(String(post.headers.cookie), /sap-secure=secure-value/, 'Secure SAP cookies must be sent through the HTTPS-backed BAS .dest route');
233
369
  assert.ok(connectTargets.length >= 2);
234
370
  assert.ok(connectTargets.every(target => target === 'cc-system.dest:80'));
235
371
  assert.equal(relay.stats.direct, false);
@@ -158,6 +158,35 @@ test('probes .dest only through configured HTTP proxy and accepts proxy-auth res
158
158
  assert.deepEqual(requests, [{ host: 'proxy-system.dest', path: 'http://proxy-system.dest/sap/bc/adt/discovery' }]);
159
159
  });
160
160
 
161
+ test('uses the documented branded HTTP proxy for destination probes and respects explicit direct mode', async t => {
162
+ const requests = [];
163
+ const proxy = createServer((request, response) => {
164
+ requests.push({ host: request.headers.host, path: request.url });
165
+ response.writeHead(200);
166
+ response.end('<ok/>');
167
+ });
168
+ await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve));
169
+ t.after(() => new Promise(resolve => proxy.close(resolve)));
170
+ const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
171
+ const found = await discoverDestinations({
172
+ body: [{ Name: 'branded-proxy-system' }],
173
+ env: { HTTP_PROXY: 'http://127.0.0.1:1', SAP_AI_DEV_TOOLKIT_HTTP_PROXY: proxyUrl }
174
+ });
175
+ assert.equal(found[0].probe.status, 'available');
176
+ assert.deepEqual(requests, [{ host: 'branded-proxy-system.dest', path: 'http://branded-proxy-system.dest/sap/bc/adt/discovery' }]);
177
+
178
+ const direct = await discoverDestinations({
179
+ body: [{ Name: 'direct-system' }],
180
+ env: { HTTP_PROXY: proxyUrl, SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '' },
181
+ probeImpl: async (_url, options) => {
182
+ assert.equal(options.proxyUrl, '', 'explicit empty branded proxy must bypass the configured proxy');
183
+ return { status: 200 };
184
+ }
185
+ });
186
+ assert.equal(direct[0].probe.status, 'available');
187
+ assert.equal(requests.length, 1);
188
+ });
189
+
161
190
  test('fetches BAS destination lists through configured HTTP and HTTPS proxies', async t => {
162
191
  const proxyRequests = [];
163
192
  const directRequests = [];
@@ -202,6 +231,26 @@ test('fetches BAS destination lists through configured HTTP and HTTPS proxies',
202
231
  assert.equal(proxyRequests.length, 2);
203
232
  });
204
233
 
234
+ test('branded HTTP proxy takes precedence over HTTPS_PROXY for secure BAS discovery', async () => {
235
+ let capturedDispatcher = 'not-called';
236
+ const body = await fetchDestinationList('https://bas.example', {
237
+ env: {
238
+ SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '',
239
+ HTTPS_PROXY: 'http://127.0.0.1:1',
240
+ https_proxy: 'http://127.0.0.1:1'
241
+ },
242
+ fetchImpl: async (_url, options) => {
243
+ capturedDispatcher = options.dispatcher;
244
+ return new Response(JSON.stringify([{ Name: 'secure-system' }]), {
245
+ status: 200,
246
+ headers: { 'content-type': 'application/json' }
247
+ });
248
+ }
249
+ });
250
+ assert.deepEqual(body, [{ Name: 'secure-system' }]);
251
+ assert.equal(capturedDispatcher, undefined, 'explicit branded empty proxy must disable inherited HTTPS_PROXY too');
252
+ });
253
+
205
254
  test('redacts credential-bearing diagnostics without exposing payloads', () => {
206
255
  const value = redact({ name: 'dev', password: 'secret', authorization: 'Bearer abc', nested: { cookie: 'x', ok: 'visible' } });
207
256
  assert.deepEqual(value, { name: 'dev', password: '[redacted]', authorization: '[redacted]', nested: { cookie: '[redacted]', ok: 'visible' } });
@@ -9,22 +9,31 @@ const destination = url.replace(/^https?:\/\//, '').replace(/\.dest$/, '');
9
9
  const logPath = process.env.FAKE_LOG;
10
10
  function log(entry) { if (logPath) appendFileSync(logPath, `${JSON.stringify({ destination, ...entry })}\n`); }
11
11
  function reply(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); }
12
- function requestADTThroughProxy(targetUrl, proxyUrl) {
12
+ function requestADTThroughProxy(targetUrl, proxyUrl, { method = 'GET', body = '', bypassProxyForLoopback = false } = {}) {
13
13
  const target = new URL(targetUrl);
14
14
  const proxy = new URL(proxyUrl);
15
15
  if (target.protocol !== 'http:') throw new Error('fixture ADT request supports HTTP only');
16
16
  const headers = { host: target.host, accept: 'application/xml,text/xml,*/*' };
17
+ if (body) headers['content-type'] = 'application/xml';
17
18
  if (process.env.SAP_USER && process.env.SAP_PASSWORD) {
18
19
  headers.authorization = `Basic ${Buffer.from(`${process.env.SAP_USER}:${process.env.SAP_PASSWORD}`).toString('base64')}`;
19
20
  }
21
+ const loopback = bypassProxyForLoopback && ['127.0.0.1', 'localhost'].includes(target.hostname);
20
22
  return new Promise((resolve, reject) => {
21
- const request = httpRequest({
23
+ const requestOptions = loopback ? {
24
+ hostname: target.hostname,
25
+ port: Number(target.port),
26
+ method,
27
+ path: `${target.pathname}${target.search}`,
28
+ headers
29
+ } : {
22
30
  hostname: proxy.hostname,
23
31
  port: Number(proxy.port),
24
- method: 'GET',
32
+ method,
25
33
  path: target.href,
26
34
  headers
27
- }, response => {
35
+ };
36
+ const request = httpRequest(requestOptions, response => {
28
37
  let body = '';
29
38
  response.setEncoding('utf8');
30
39
  response.on('data', chunk => { body += chunk; });
@@ -32,6 +41,7 @@ function requestADTThroughProxy(targetUrl, proxyUrl) {
32
41
  });
33
42
  request.setTimeout(5_000, () => request.destroy(new Error('fixture ADT request timed out')));
34
43
  request.on('error', reject);
44
+ if (body) request.write(body);
35
45
  request.end();
36
46
  });
37
47
  }
@@ -234,6 +244,15 @@ process.stdin.on('data', async chunk => {
234
244
  } catch {
235
245
  reply(message.id, { content: [{ type: 'text', text: 'fixture ADT request failed' }], isError: true });
236
246
  }
247
+ } else if (process.env.FAKE_RELAY_CSRF_TEST === 'true' && name === 'GetSystemInfo') {
248
+ try {
249
+ const target = new URL('/sap/bc/adt/datapreview/freestyle', url);
250
+ const proxy = process.env.HTTP_PROXY || process.env.http_proxy;
251
+ const adt = await requestADTThroughProxy(target.href, proxy, { method: 'POST', body: '<probe/>', bypassProxyForLoopback: true });
252
+ reply(message.id, { content: [{ type: 'text', text: `fixture ADT HTTP ${adt.status}: ${adt.body}` }], isError: adt.status < 200 || adt.status >= 300 });
253
+ } catch {
254
+ reply(message.id, { content: [{ type: 'text', text: 'fixture ADT write failed' }], isError: true });
255
+ }
237
256
  } else {
238
257
  reply(message.id, { content: [{ type: 'text', text: `${destination}:${name}` }], isError: false });
239
258
  }
@@ -134,6 +134,152 @@ test('doctor reports missing BAS configuration as redacted JSON', async () => {
134
134
  });
135
135
  });
136
136
 
137
+ test('doctor preflights the failing ADT endpoint through the runtime relay and repairs its owned config', async t => {
138
+ const directory = await mkdtemp(join(tmpdir(), 'bas-doctor-csrf-'));
139
+ const configPath = join(directory, 'mcp.json');
140
+ const config = {
141
+ servers: {
142
+ 'doctor-system': {
143
+ type: 'stdio',
144
+ command: 'sap-ai-dev',
145
+ env: {
146
+ H2O_URL: 'http://stale-bas.example',
147
+ SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system'
148
+ },
149
+ BAS_EXT: 'true'
150
+ },
151
+ ActionS4D_100: { type: 'stdio', command: 'third-party' }
152
+ }
153
+ };
154
+ await writeFile(configPath, JSON.stringify(config));
155
+ const requests = [];
156
+ const server = createServer((request, response) => {
157
+ let requestPath;
158
+ try { requestPath = new URL(request.url, `http://${request.headers.host}`).pathname; }
159
+ catch { requestPath = request.url; }
160
+ requests.push({ method: request.method, path: requestPath, csrf: request.headers['x-csrf-token'], cookie: request.headers.cookie });
161
+ if (request.url === '/api/listDestinations') {
162
+ response.setHeader('content-type', 'application/json');
163
+ response.end(JSON.stringify([{
164
+ Name: 'doctor-system',
165
+ WebIDEEnabled: true,
166
+ 'HTML5.DynamicDestination': true,
167
+ WebIDEUsage: 'dev_abap',
168
+ Authentication: 'BasicAuthentication'
169
+ }]));
170
+ return;
171
+ }
172
+ if (requestPath === '/sap/bc/adt/datapreview/freestyle') {
173
+ if (request.method === 'GET' && request.headers['x-csrf-token'] === 'Fetch') {
174
+ response.writeHead(200, {
175
+ 'x-csrf-token': 'doctor-token',
176
+ 'set-cookie': 'sap-session=doctor-session; Path=/'
177
+ });
178
+ response.end('<ok/>');
179
+ return;
180
+ }
181
+ if (request.method === 'POST'
182
+ && request.headers['x-csrf-token'] === 'doctor-token'
183
+ && String(request.headers.cookie).includes('sap-session=doctor-session')) {
184
+ response.writeHead(200);
185
+ response.end('<written/>');
186
+ return;
187
+ }
188
+ response.writeHead(403, { 'x-csrf-token': 'Required' });
189
+ response.end('CSRF token validation failed');
190
+ return;
191
+ }
192
+ response.writeHead(200);
193
+ response.end('<ok/>');
194
+ });
195
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
196
+ t.after(async () => {
197
+ await new Promise(resolve => server.close(resolve));
198
+ await rm(directory, { recursive: true, force: true });
199
+ });
200
+ const proxy = `http://127.0.0.1:${server.address().port}`;
201
+ const result = await runLauncher(['--doctor', '--json'], {
202
+ ...process.env,
203
+ SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
204
+ FAKE_RELAY_CSRF_TEST: 'true',
205
+ SAP_AI_DEV_MCP_CONFIG: configPath,
206
+ SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system',
207
+ H2O_URL: `http://127.0.0.1:${server.address().port}`,
208
+ HTTP_PROXY: proxy,
209
+ http_proxy: proxy,
210
+ NO_PROXY: '127.0.0.1,localhost',
211
+ no_proxy: '127.0.0.1,localhost'
212
+ });
213
+ assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`);
214
+ const report = JSON.parse(result.stdout);
215
+ assert.equal(report.ok, true);
216
+ assert.ok(report.checks.some(check => check.stage === 'BAS relay CSRF preflight' && check.status === 'passed'));
217
+ assert.ok(report.checks.some(check => check.stage === 'SAP system check' && check.status === 'passed'));
218
+ assert.ok(report.checks.some(check => check.stage === 'MCP config repair' && check.status === 'passed' && /1 managed BAS entry repaired/.test(check.detail)));
219
+ assert.ok(report.checks.some(check => check.stage === 'MCP tools/list' && /total MCP tools returned; chat-picker binding is host-managed/.test(check.detail)));
220
+ assert.ok(requests.some(request => request.method === 'GET' && request.path === '/sap/bc/adt/datapreview/freestyle' && request.csrf === 'Fetch'));
221
+ assert.ok(requests.some(request => request.method === 'POST' && request.path === '/sap/bc/adt/datapreview/freestyle' && request.csrf === 'doctor-token' && String(request.cookie).includes('sap-session=doctor-session')));
222
+ const repairedConfig = JSON.parse(await readFile(configPath, 'utf8'));
223
+ assert.equal(repairedConfig.servers['doctor-system'].env.H2O_URL, `http://127.0.0.1:${server.address().port}`);
224
+ assert.deepEqual(repairedConfig.servers.ActionS4D_100, config.servers.ActionS4D_100);
225
+ });
226
+
227
+ test('doctor stops before GetSystemInfo when the runtime CSRF preflight has no session cookie', async t => {
228
+ const directory = await mkdtemp(join(tmpdir(), 'bas-doctor-csrf-missing-cookie-'));
229
+ const configPath = join(directory, 'mcp.json');
230
+ await writeFile(configPath, JSON.stringify({ servers: {} }));
231
+ const requests = [];
232
+ const server = createServer((request, response) => {
233
+ let requestPath;
234
+ try { requestPath = new URL(request.url, `http://${request.headers.host}`).pathname; }
235
+ catch { requestPath = request.url; }
236
+ requests.push({ method: request.method, path: requestPath, csrf: request.headers['x-csrf-token'] });
237
+ if (request.url === '/api/listDestinations') {
238
+ response.setHeader('content-type', 'application/json');
239
+ response.end(JSON.stringify([{
240
+ Name: 'doctor-system',
241
+ WebIDEEnabled: true,
242
+ 'HTML5.DynamicDestination': true,
243
+ WebIDEUsage: 'dev_abap',
244
+ Authentication: 'BasicAuthentication'
245
+ }]));
246
+ return;
247
+ }
248
+ if (requestPath === '/sap/bc/adt/datapreview/freestyle') {
249
+ response.writeHead(200, { 'x-csrf-token': 'orphan-token' });
250
+ response.end('<ok/>');
251
+ return;
252
+ }
253
+ response.writeHead(200);
254
+ response.end('<ok/>');
255
+ });
256
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
257
+ t.after(async () => {
258
+ await new Promise(resolve => server.close(resolve));
259
+ await rm(directory, { recursive: true, force: true });
260
+ });
261
+ const proxy = `http://127.0.0.1:${server.address().port}`;
262
+ const result = await runLauncher(['--doctor', '--json'], {
263
+ ...process.env,
264
+ SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
265
+ FAKE_RELAY_CSRF_TEST: 'true',
266
+ SAP_AI_DEV_MCP_CONFIG: configPath,
267
+ SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system',
268
+ H2O_URL: `http://127.0.0.1:${server.address().port}`,
269
+ HTTP_PROXY: proxy,
270
+ http_proxy: proxy,
271
+ NO_PROXY: '127.0.0.1,localhost',
272
+ no_proxy: '127.0.0.1,localhost'
273
+ });
274
+ assert.equal(result.code, 1);
275
+ const report = JSON.parse(result.stdout);
276
+ assert.equal(report.ok, false);
277
+ assert.ok(report.checks.some(check => check.stage === 'BAS relay CSRF preflight' && check.status === 'failed' && /cookies=0; usableSession=false/.test(check.detail)));
278
+ assert.ok(report.checks.some(check => check.stage === 'SAP system check' && check.status === 'skipped' && /validated BAS proxy tunnel credential override/.test(check.detail)));
279
+ assert.equal(requests.some(request => request.method === 'GET' && request.path === '/sap/bc/adt/datapreview/freestyle' && request.csrf === 'Fetch'), true);
280
+ assert.equal(requests.some(request => request.method === 'POST' && request.path === '/sap/bc/adt/datapreview/freestyle'), false, 'doctor must not call GetSystemInfo after a failed preflight');
281
+ });
282
+
137
283
  test('list-destinations JSON is redacted and probes through the BAS proxy', async () => {
138
284
  const requests = [];
139
285
  const server = createServer((request, response) => {
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { buildMcpEntries, buildSapDevelopmentMcpEntries, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig } from '../src/mcp-config.mjs';
6
+ import { buildMcpEntries, buildSapDevelopmentMcpEntries, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, repairManagedMcpConfig, resolveMcpConfigPath } from '../src/mcp-config.mjs';
7
7
 
8
8
  const bas = {
9
9
  source: 'bas', name: 'shared', serverName: 'shared', url: 'http://shared.dest',
@@ -78,6 +78,134 @@ test('stores and reconciles the MCP launcher executable location', async t => {
78
78
  assert.deepEqual(Object.keys((await readMcpConfig(path)).servers), ['userServer']);
79
79
  });
80
80
 
81
+ test('resolves documented, toolkit, and legacy MCP config paths in order', async () => {
82
+ assert.equal(await resolveMcpConfigPath({
83
+ SAP_AI_DEV_MCP_CONFIG: '/tmp/documented-mcp.json',
84
+ SAP_AI_DEV_TOOLKIT_MCP_CONFIG: '/tmp/toolkit-mcp.json',
85
+ BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json'
86
+ }), '/tmp/documented-mcp.json');
87
+ assert.equal(await resolveMcpConfigPath({
88
+ SAP_AI_DEV_TOOLKIT_MCP_CONFIG: '/tmp/toolkit-mcp.json',
89
+ BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json'
90
+ }), '/tmp/toolkit-mcp.json');
91
+ assert.equal(await resolveMcpConfigPath({ BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json' }), '/tmp/legacy-mcp.json');
92
+ });
93
+
94
+ test('doctor repair updates only discovered toolkit BAS entries and is idempotent', async t => {
95
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-'));
96
+ const path = join(directory, 'mcp.json');
97
+ t.after(() => rm(directory, { recursive: true, force: true }));
98
+ const cf = cfDestination('space-one', 'instance-one', 'cf-key');
99
+ const strictDestination = { ...bas, name: 'strict', serverName: 'strict', url: 'http://strict.dest' };
100
+ const managed = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
101
+ const strictManaged = buildMcpEntries([strictDestination], { H2O_URL: 'http://old-h2o.example' }).strict;
102
+ const cfEntry = buildMcpEntries([cf], { H2O_URL: 'http://cf-h2o.example' })[cf.serverName];
103
+ const companion = buildSapDevelopmentMcpEntries(['sap-fiori-tools'])['sap-fiori-tools'];
104
+ const unrelated = { type: 'stdio', command: 'external-server' };
105
+ const unverifiedManaged = {
106
+ ...managed,
107
+ env: { ...managed.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'temporarily-unavailable' }
108
+ };
109
+ const config = {
110
+ userFlag: true,
111
+ servers: {
112
+ shared: { ...managed, env: { ...managed.env, H2O_URL: 'http://old-h2o.example' } },
113
+ strict: { ...strictManaged, env: { ...strictManaged.env, SAP_ALLOW_TRANSPORTABLE_EDITS: 'false' } },
114
+ 'temporarily-unavailable': unverifiedManaged,
115
+ [cf.serverName]: cfEntry,
116
+ 'sap-fiori-tools': companion,
117
+ ActionS4D_100: unrelated
118
+ }
119
+ };
120
+ await writeFile(path, JSON.stringify(config, null, 2));
121
+
122
+ const first = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
123
+ assert.equal(first.changed, true);
124
+ assert.equal(first.repaired, 2);
125
+ const repaired = await readMcpConfig(path);
126
+ assert.equal(repaired.userFlag, true);
127
+ assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
128
+ assert.equal(repaired.servers.shared.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'true');
129
+ assert.equal(repaired.servers.strict.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'false', "doctor must preserve a user's restricted write setting");
130
+ assert.deepEqual(repaired.servers['temporarily-unavailable'], unverifiedManaged);
131
+ assert.deepEqual(repaired.servers[cf.serverName], cfEntry);
132
+ assert.deepEqual(repaired.servers['sap-fiori-tools'], companion);
133
+ assert.deepEqual(repaired.servers.ActionS4D_100, unrelated);
134
+ assert.equal(repaired.servers.newDestination, undefined, 'repair must not add unselected destinations');
135
+
136
+ const beforeSecondRepair = await readFile(path, 'utf8');
137
+ const second = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
138
+ assert.equal(second.changed, false);
139
+ assert.equal(second.repaired, 0);
140
+ assert.equal(await readFile(path, 'utf8'), beforeSecondRepair, 'an already-healed config must not be rewritten');
141
+ });
142
+
143
+ test('doctor repair leaves malformed config untouched', async t => {
144
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-invalid-'));
145
+ const path = join(directory, 'mcp.json');
146
+ const malformed = '{ "servers": ';
147
+ await writeFile(path, malformed);
148
+ t.after(() => rm(directory, { recursive: true, force: true }));
149
+ await assert.rejects(
150
+ () => repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example' }, path, discoveryComplete: true }),
151
+ /invalid JSON/
152
+ );
153
+ assert.equal(await readFile(path, 'utf8'), malformed);
154
+ });
155
+
156
+ test('doctor repair skips config changes if destination discovery is incomplete', async t => {
157
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-incomplete-'));
158
+ const path = join(directory, 'mcp.json');
159
+ const entry = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
160
+ const initial = JSON.stringify({ servers: { shared: entry } });
161
+ await writeFile(path, initial);
162
+ t.after(() => rm(directory, { recursive: true, force: true }));
163
+ const result = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://new-h2o.example' }, path });
164
+ assert.equal(result.changed, false);
165
+ assert.match(result.skipped, /incomplete/);
166
+ assert.equal(await readFile(path, 'utf8'), initial);
167
+ });
168
+
169
+ test('doctor repair refreshes stale toolkit launchers without changing npx behavior', async t => {
170
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-launcher-repair-'));
171
+ const path = join(directory, 'mcp.json');
172
+ const bin = join(directory, 'bin');
173
+ await import('node:fs/promises').then(({ mkdir }) => mkdir(bin));
174
+ const command = join(bin, 'sap-ai-dev');
175
+ await writeFile(command, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
176
+ t.after(() => rm(directory, { recursive: true, force: true }));
177
+ const npx = {
178
+ ...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared,
179
+ command: 'npx',
180
+ args: ['--yes', '--package=sap-ai-dev-toolkit@0.4.2', 'sap-ai-dev'],
181
+ env: {
182
+ ...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared.env,
183
+ SAP_AI_DEV_TOOLKIT_DESTINATION: 'shared',
184
+ OPERATOR_SETTING: 'preserve'
185
+ }
186
+ };
187
+ const direct = {
188
+ ...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared,
189
+ command: 'stale-sap-ai-dev'
190
+ };
191
+ await writeFile(path, JSON.stringify({ servers: { shared: npx, other: { ...direct, env: { ...direct.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'other' } }, userServer: { command: 'other' } } }));
192
+
193
+ const result = await repairManagedMcpConfig([bas], {
194
+ env: { H2O_URL: 'http://current-h2o.example', PATH: bin },
195
+ path,
196
+ discoveryComplete: true,
197
+ packageVersion: '0.4.5'
198
+ });
199
+ assert.equal(result.repaired, 1, 'only the entry that maps to the discovered destination is repaired');
200
+ const repaired = await readMcpConfig(path);
201
+ assert.equal(repaired.servers.shared.command, 'npx');
202
+ assert.deepEqual(repaired.servers.shared.args, ['--yes', '--package=sap-ai-dev-toolkit@0.4.5', 'sap-ai-dev']);
203
+ assert.equal(repaired.servers.shared.env.OPERATOR_SETTING, 'preserve');
204
+ assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
205
+ assert.equal(repaired.servers.other.command, 'stale-sap-ai-dev', 'unmatched toolkit entry must remain untouched');
206
+ assert.deepEqual(repaired.servers.userServer, { command: 'other' });
207
+ });
208
+
81
209
  test('extracts key references only from package-managed Cloud Foundry entries', () => {
82
210
  const cloudFoundry = buildMcpEntries([
83
211
  cfDestination('space-one', 'instance-one', 'destination-key', 'OnPremise')
@@ -271,6 +271,36 @@ test('merges paged tools and routes calls to the selected child', async t => {
271
271
  assert.equal(init.env.allowTransportableEdits, 'true');
272
272
  }
273
273
  });
274
+
275
+ test('routes tools/call using the exact name advertised for a BAS destination', async t => {
276
+ const directory = await mkdtemp(join(tmpdir(), 'bas-namespaced-tool-roundtrip-'));
277
+ const log = join(directory, 'children.log');
278
+ const logs = [];
279
+ const proxy = new MCPProxy({
280
+ binary: fixture,
281
+ destinations: [{ name: 'ActionS4D_100', url: 'http://ActionS4D_100.dest', client: '100' }],
282
+ env: { ...process.env, SAP_AI_DEV_TOOLKIT_MODE: 'expert', FAKE_LOG: log },
283
+ log: message => logs.push(message)
284
+ });
285
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
286
+ proxy.start();
287
+ const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
288
+ assert.equal(initialized.error, undefined, `${JSON.stringify(initialized.error)} ${logs.join(' | ')}`);
289
+ const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
290
+ assert.equal(listed.error, undefined, JSON.stringify(listed.error));
291
+ const tool = listed.result.tools.find(candidate => candidate.name === 'actions4d-100__WriteSource');
292
+ assert.ok(tool, 'tools/list must publish the normalized destination namespace');
293
+ const called = await proxy.handle({
294
+ jsonrpc: '2.0',
295
+ id: 3,
296
+ method: 'tools/call',
297
+ params: { name: tool.name, arguments: { source: 'REPORT zself_heal.' } }
298
+ });
299
+ assert.equal(called.error, undefined);
300
+ assert.equal(called.result.content[0].text, 'ActionS4D_100:WriteSource');
301
+ const calls = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).filter(entry => entry.event === 'call');
302
+ assert.deepEqual(calls.map(entry => entry.name), ['WriteSource']);
303
+ });
274
304
  test('runs LintABAP locally and returns parser findings', async t => {
275
305
  const { directory, log, proxy } = await fixtureProxy();
276
306
  t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });